Ver en GitHub

Estrategia Maximus vX Lite

Conversión del asesor experto de MetaTrader 5 "maximus_vX lite" a la API de alto nivel de StockSharp. La estrategia busca zonas de consolidación por encima y por debajo del precio actual y espera a que el precio se mueva un número configurable de puntos alejándose de esas zonas antes de entrar. El tamaño de la posición se determina a partir de un presupuesto de porcentaje de riesgo opcional, y el beneficio flotante puede desencadenar una liquidación forzada de toda la exposición abierta.

Lógica de la estrategia

  1. Escaneo histórico – en cada vela finalizada la estrategia mantiene hasta HistoryDepth velas y usa una ventana deslizante RangeLookback para detectar máximos y mínimos compactos que forman áreas de consolidación.
  2. Canal superior – cuando se detecta un bloque superior válido, el canal se ancla alrededor del cierre actual con un ancho de RangePoints. Si ningún bloque histórico cumple los requisitos, el canal recurre al mismo ancho ajustado al precio actual.
  3. Canal inferior – el bloque inferior se toma directamente de los máximos/mínimos históricos que satisfacen las condiciones de rango o, si no existen, de un nivel sintético alrededor del cierre actual menos RangePoints.
  4. Entradas largas – se permiten dos configuraciones largas:
    • Ruptura por encima de la consolidación inferior: el precio debe superar _lowerMax por DistancePoints y el canal superior debe estar disponible. El take profit usa dos tercios de la distancia entre _lowerMax y _upperMin, con un mínimo igual a RangePoints.
    • Ruptura por encima del canal superior: el precio debe superar _upperMax por DistancePoints. El take profit se establece en 2 * RangePoints.
  5. Entradas cortas – la lógica simétrica se dispara cuando el precio cae por debajo de _upperMin o _lowerMin por DistancePoints. La configuración corta primaria también usa el objetivo dinámico de dos tercios, mientras que la secundaria usa 2 * RangePoints.
  6. Stops y salidasStopLossPoints define un stop protector fijo cuando es mayor que cero. MinProfitPercent monitorea el capital flotante frente al último balance plano y cierra todas las posiciones una vez superado el umbral. Las comprobaciones manuales de stop/objetivo emulan el comportamiento del asesor experto original dentro de la estrategia.
  7. Dimensionamiento de posición – cuando RiskPercent es mayor que cero y hay un stop definido, el volumen de la orden se calcula a partir del valor del portafolio y la distancia del stop. De lo contrario, la estrategia reutiliza la propiedad Volume.

Parámetros

  • DelayOpen (predeterminado 2) – número de barras del período de tiempo durante las cuales se permite añadir al mismo lado.
  • DistancePoints (predeterminado 850) – distancia mínima desde el borde de consolidación antes de entrar.
  • RangePoints (predeterminado 500) – ancho de las cajas de consolidación.
  • HistoryDepth (predeterminado 1000) – número de velas guardadas en memoria para escaneos históricos.
  • RangeLookback (predeterminado 40) – longitud de ventana usada para calcular máximos y mínimos locales.
  • CandleType (predeterminado TimeSpan.FromMinutes(15).TimeFrame()) – período de tiempo usado para los cálculos.
  • RiskPercent (predeterminado 5m) – porcentaje del valor del portafolio arriesgado por operación. Poner en cero para usar volumen fijo.
  • StopLossPoints (predeterminado 1000) – distancia del stop protector; cero deshabilita el stop.
  • MinProfitPercent (predeterminado 1m) – porcentaje de beneficio flotante que fuerza el cierre de todas las posiciones.

Detalles

  • Largo/Corto: Ambas direcciones
  • Criterios de salida: Stop fijo o take profit, bloqueo de capital mediante MinProfitPercent
  • Stops: Stop fijo opcional desde StopLossPoints
  • Indicadores: Ninguno (precio puro con análisis de ventana deslizante)
  • Marco temporal: Configurable mediante CandleType (predeterminado 15 minutos)
  • Complejidad: Intermedio (combina escaneo de historial, objetivos dinámicos y dimensionamiento de riesgo)
  • Nivel de riesgo: Alto cuando se usa porcentaje de riesgo debido a la naturaleza de ruptura
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>
/// Implements the Maximus vX Lite consolidation breakout strategy.
/// </summary>
public class MaximusVXLiteStrategy : Strategy
{
	private readonly StrategyParam<int> _delayOpen;
	private readonly StrategyParam<int> _distancePoints;
	private readonly StrategyParam<int> _rangePoints;
	private readonly StrategyParam<int> _historyDepth;
	private readonly StrategyParam<int> _rangeLookback;
	private readonly StrategyParam<DataType> _candleType;
	private readonly StrategyParam<decimal> _riskPercent;
	private readonly StrategyParam<int> _stopLossPoints;
	private readonly StrategyParam<decimal> _minProfitPercent;

	private readonly List<CandleInfo> _history = new();

	private decimal _upperMax;
	private decimal _upperMin;
	private decimal _lowerMax;
	private decimal _lowerMin;

	private decimal _priceStep = 1m;
	private decimal _extDistance;
	private decimal _extRange;
	private decimal _extStopLoss;

	private DateTimeOffset? _lastBuyTime;
	private DateTimeOffset? _lastSellTime;
	private decimal _lastKnownBalance;

	private decimal? _activeStop;
	private decimal? _activeTake;

	private readonly struct CandleInfo
	{
		public CandleInfo(decimal high, decimal low)
		{
			High = high;
			Low = low;
		}

		public decimal High { get; }

		public decimal Low { get; }
	}

	/// <summary>
	/// Number of timeframe intervals allowed before averaging into the same direction.
	/// </summary>
	public int DelayOpen
	{
		get => _delayOpen.Value;
		set => _delayOpen.Value = value;
	}

	/// <summary>
	/// Minimum distance in points from the consolidation band before entering a trade.
	/// </summary>
	public int DistancePoints
	{
		get => _distancePoints.Value;
		set => _distancePoints.Value = value;
	}

	/// <summary>
	/// Size of the consolidation range in points.
	/// </summary>
	public int RangePoints
	{
		get => _rangePoints.Value;
		set => _rangePoints.Value = value;
	}

	/// <summary>
	/// Number of historical candles considered when searching for ranges.
	/// </summary>
	public int HistoryDepth
	{
		get => _historyDepth.Value;
		set => _historyDepth.Value = value;
	}

	/// <summary>
	/// Window length used to calculate local highs and lows.
	/// </summary>
	public int RangeLookback
	{
		get => _rangeLookback.Value;
		set => _rangeLookback.Value = value;
	}

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

	/// <summary>
	/// Risk budget per trade expressed as percent of portfolio value.
	/// </summary>
	public decimal RiskPercent
	{
		get => _riskPercent.Value;
		set => _riskPercent.Value = value;
	}

	/// <summary>
	/// Stop loss distance in points.
	/// </summary>
	public int StopLossPoints
	{
		get => _stopLossPoints.Value;
		set => _stopLossPoints.Value = value;
	}

	/// <summary>
	/// Floating profit percentage that triggers forced position close.
	/// </summary>
	public decimal MinProfitPercent
	{
		get => _minProfitPercent.Value;
		set => _minProfitPercent.Value = value;
	}

	/// <summary>
	/// Initializes a new instance of the <see cref="MaximusVXLiteStrategy"/> class.
	/// </summary>
	public MaximusVXLiteStrategy()
	{
		_delayOpen = Param(nameof(DelayOpen), 2)
			.SetNotNegative()
			.SetDisplay("Delay Open", "How many timeframe periods allow averaging in the same direction", "Trading Rules");

		_distancePoints = Param(nameof(DistancePoints), 850)
			.SetGreaterThanZero()
			.SetDisplay("Distance", "Minimum distance from consolidation band before trading", "Trading Rules");

		_rangePoints = Param(nameof(RangePoints), 500)
			.SetGreaterThanZero()
			.SetDisplay("Range", "Width of consolidation channel in points", "Trading Rules");

		_historyDepth = Param(nameof(HistoryDepth), 200)
			.SetGreaterThanZero()
			.SetDisplay("History Depth", "Number of candles inspected for consolidation zones", "Data");

		_rangeLookback = Param(nameof(RangeLookback), 40)
			.SetGreaterThanZero()
			.SetDisplay("Range Lookback", "Candles used to calculate local maxima and minima", "Data");

		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(5).TimeFrame())
			.SetDisplay("Candle Type", "Primary timeframe used by the strategy", "General");

		_riskPercent = Param(nameof(RiskPercent), 5m)
			.SetNotNegative()
			.SetDisplay("Risk Percent", "Portfolio percent risked per trade", "Risk");

		_stopLossPoints = Param(nameof(StopLossPoints), 1000)
			.SetNotNegative()
			.SetDisplay("Stop Loss", "Stop loss distance in points", "Risk");

		_minProfitPercent = Param(nameof(MinProfitPercent), 1m)
			.SetNotNegative()
			.SetDisplay("Min Profit Percent", "Floating profit percent required to close all positions", "Risk");
	}

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

	/// <inheritdoc />
	protected override void OnReseted()
	{
		base.OnReseted();
		_history.Clear();
		_upperMax = _upperMin = _lowerMax = _lowerMin = 0m;
		_priceStep = 0m;
		_extDistance = 0m;
		_extRange = 0m;
		_extStopLoss = 0m;
		_lastBuyTime = null;
		_lastSellTime = null;
		_activeStop = null;
		_activeTake = null;
		_lastKnownBalance = 0m;
	}

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

		UpdateDerivedValues();
		_lastKnownBalance = Portfolio?.CurrentValue ?? 0m;

		var subscription = SubscribeCandles(CandleType);

		subscription
			.Bind(ProcessCandle)
			.Start();

		StartProtection(
			takeProfit: new Unit(3, UnitTypes.Percent),
			stopLoss: new Unit(2, UnitTypes.Percent));

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

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

		UpdateDerivedValues();

		try
		{
			UpdateHistory(candle);
		}
		catch (ArgumentOutOfRangeException)
		{
			return;
		}

		if (Position == 0m)
		{
			var balance = Portfolio?.CurrentValue;
			if (balance.HasValue && balance.Value > 0m)
				_lastKnownBalance = balance.Value;

			_activeStop = null;
			_activeTake = null;
		}

		try
		{
			FindHighLow(candle.ClosePrice);
		}
		catch (ArgumentOutOfRangeException)
		{
			return;
		}

		if (HandleStopsAndTargets(candle))
			return;

		TryEnterPositions(candle);
		TryLockInProfit();
	}

	private void UpdateHistory(ICandleMessage candle)
	{
		// Store the latest completed candle at the front of the list.
		_history.Insert(0, new CandleInfo(candle.HighPrice, candle.LowPrice));
		while (_history.Count > HistoryDepth)
			_history.RemoveAt(_history.Count - 1);
	}

	private void UpdateDerivedValues()
	{
		// Convert point-based parameters to actual price distances using the security step.
		var step = Security?.PriceStep ?? 1m;
		if (step <= 0m)
			step = 1m;

		_priceStep = step;
		_extDistance = DistancePoints * step;
		_extRange = RangePoints * step;
		_extStopLoss = StopLossPoints * step;
	}

	private void FindHighLow(decimal currentClose)
	{
		if (_history.Count == 0)
			return;

		var recalc = currentClose - 100m * _priceStep > _lowerMax
			|| currentClose + 100m * _priceStep < _lowerMin
			|| currentClose - 100m * _priceStep > _upperMax
			|| currentClose + 100m * _priceStep < _upperMin;

		if (!recalc)
			return;

		var foundUpper = false;
		for (var i = 0; i < _history.Count; i++)
		{
			var high = _history[i].High;
			if (currentClose - _extRange <= high)
				continue;

			var (windowMax, windowMin) = GetRangeWindow(i);
			if (windowMax == 0m && windowMin == 0m)
				continue;

			if (windowMax - windowMin <= _extRange && currentClose + _extRange > windowMax && currentClose + _extRange > windowMin)
			{
				foundUpper = true;
				break;
			}
		}

		var halfRange = RangePoints * 0.5m * _priceStep;
		if (!foundUpper)
		{
			var baseValue = Math.Floor(currentClose + 100m * _priceStep);
			_upperMax = baseValue + halfRange;
			_upperMin = baseValue - halfRange;
		}
		else
		{
			var baseValue = Math.Floor((currentClose + 100m * _priceStep) * 100m) / 100m;
			_upperMax = baseValue + halfRange;
			_upperMin = baseValue - halfRange;
		}

		var lowerFound = false;
		decimal lowerMax = 0m;
		decimal lowerMin = 0m;

		for (var i = 0; i < _history.Count; i++)
		{
			var high = _history[i].High;
			if (currentClose - _extRange <= high)
				continue;

			var (windowMax, windowMin) = GetRangeWindow(i);
			if (windowMax == 0m && windowMin == 0m)
				continue;

			if (windowMax - windowMin <= _extRange && currentClose - _extRange > windowMax && currentClose - _extRange > windowMin)
			{
				lowerMax = windowMax;
				lowerMin = windowMin;
				lowerFound = true;
				break;
			}
		}

		if (!lowerFound)
		{
			var baseValue = Math.Floor((currentClose - 100m * _priceStep) * 100m) / 100m;
			lowerMax = baseValue + halfRange;
			lowerMin = baseValue - halfRange;
		}

		_lowerMax = lowerMax;
		_lowerMin = lowerMin;
	}

	private (decimal max, decimal min) GetRangeWindow(int startIndex)
	{
		// Replicates ArrayMaximum/ArrayMinimum over a sliding window.
		var count = Math.Min(RangeLookback, _history.Count - startIndex);
		if (count <= 0)
			return (0m, 0m);

		var max = decimal.MinValue;
		var min = decimal.MaxValue;

		for (var j = 0; j < count; j++)
		{
			var index = startIndex + j;
			if (index >= _history.Count)
				break;

			var item = _history[index];
			if (item.High > max)
				max = item.High;
			if (item.Low < min)
				min = item.Low;
		}

		return (max, min);
	}

	private bool HandleStopsAndTargets(ICandleMessage candle)
	{
		// Manually exit positions when candle extremes touch the stored stop or target.
		if (Position > 0m)
		{
			if (_activeStop.HasValue && candle.LowPrice <= _activeStop.Value)
			{
				SellMarket(Position);
				ResetAfterExit();
				return true;
			}

			if (_activeTake.HasValue && candle.HighPrice >= _activeTake.Value)
			{
				SellMarket(Position);
				ResetAfterExit();
				return true;
			}
		}
		else if (Position < 0m)
		{
			var volume = Math.Abs(Position);
			if (_activeStop.HasValue && candle.HighPrice >= _activeStop.Value)
			{
				BuyMarket(volume);
				ResetAfterExit();
				return true;
			}

			if (_activeTake.HasValue && candle.LowPrice <= _activeTake.Value)
			{
				BuyMarket(volume);
				ResetAfterExit();
				return true;
			}
		}

		return false;
	}

	private void TryEnterPositions(ICandleMessage candle)
	{
		var price = candle.ClosePrice;
		if (price <= 0m)
			return;

		var timeFrame = CandleType.Arg is TimeSpan span ? span : TimeSpan.Zero;
		var delayDuration = TimeSpan.FromTicks(timeFrame.Ticks * DelayOpen);
		var now = candle.CloseTime;

		var hasLong = Position > 0m;
		var hasShort = Position < 0m;

		var allowAdditionalBuy = !hasLong;
		if (DelayOpen > 0 && hasLong)
			allowAdditionalBuy = _lastBuyTime.HasValue && (_lastBuyTime.Value + delayDuration) > now;
		else if (DelayOpen == 0 && hasLong)
			allowAdditionalBuy = false;

		var allowAdditionalSell = !hasShort;
		if (DelayOpen > 0 && hasShort)
			allowAdditionalSell = _lastSellTime.HasValue && (_lastSellTime.Value + delayDuration) > now;
		else if (DelayOpen == 0 && hasShort)
			allowAdditionalSell = false;

		var volume = CalculateOrderVolume();
		if (volume <= 0m)
			return;

		var buyPrimary = _lowerMax != 0m && _upperMin != 0m && price - _extDistance > _lowerMax;
		var buySecondary = _upperMax != 0m && price - _extDistance > _upperMax;

		if (allowAdditionalBuy && (buyPrimary || buySecondary) && Position <= 0m)
		{
			var stopPrice = StopLossPoints == 0 ? (decimal?)null : price - _extStopLoss;
			decimal? takePrice;

			if (buyPrimary)
			{
				var diff = _upperMin - _lowerMax;
				var tempTp = diff / 3m * 2m * _priceStep;
				if (tempTp < _extRange)
					tempTp = _extRange;
				takePrice = price + tempTp;
			}
			else
			{
				takePrice = price + 2m * _extRange;
			}

			var orderVolume = volume + (Position < 0m ? Math.Abs(Position) : 0m);
			BuyMarket(orderVolume);
			_activeStop = stopPrice;
			_activeTake = takePrice;
			_lastBuyTime = now;
			return;
		}

		var sellPrimary = _upperMin != 0m && price + _extDistance < _upperMin;
		var sellSecondary = _lowerMin != 0m && price + _extDistance < _lowerMin;

		if (allowAdditionalSell && (sellPrimary || sellSecondary) && Position >= 0m)
		{
			var stopPrice = StopLossPoints == 0 ? (decimal?)null : price + _extStopLoss;
			decimal? takePrice;

			if (sellPrimary)
			{
				var diff = _upperMin - _lowerMax;
				var tempTp = diff / 3m * 2m * _priceStep;
				if (tempTp < _extRange)
					tempTp = _extRange;
				takePrice = price - tempTp;
			}
			else
			{
				takePrice = price - 2m * _extRange;
			}

			var orderVolume = volume + (Position > 0m ? Math.Abs(Position) : 0m);
			SellMarket(orderVolume);
			_activeStop = stopPrice;
			_activeTake = takePrice;
			_lastSellTime = now;
		}
	}

	private decimal CalculateOrderVolume()
	{
		// Use risk budget when a stop loss is available, otherwise fall back to base volume.
		var baseVolume = Volume;
		if (RiskPercent > 0m && _extStopLoss > 0m)
		{
			var capital = Portfolio?.CurrentValue ?? 0m;
			if (capital > 0m)
			{
				var riskBudget = capital * (RiskPercent / 100m);
				if (riskBudget > 0m)
				{
					var rawVolume = riskBudget / _extStopLoss;
					if (rawVolume > 0m)
						baseVolume = rawVolume;
				}
			}
		}

		var step = Security?.VolumeStep ?? 1m;
		if (step <= 0m)
			step = 1m;

		var adjusted = Math.Floor(baseVolume / step) * step;
		if (adjusted <= 0m)
			adjusted = step;

		return adjusted;
	}

	private void TryLockInProfit()
	{
		if (Position == 0m)
			return;

		var balance = _lastKnownBalance;
		if (balance <= 0m)
			return;

		var equity = Portfolio?.CurrentValue ?? balance;
		var profitPercent = (equity - balance) / balance * 100m;
		if (profitPercent > MinProfitPercent)
		{
			ClosePosition();
			ResetAfterExit();
		}
	}

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

	private void ResetAfterExit()
	{
		// Clear timers and protective orders so the next setup starts clean.
		_activeStop = null;
		_activeTake = null;
		_lastBuyTime = null;
		_lastSellTime = null;
	}
}