Ver en GitHub

Estrategia Martin 1

Conversión del asesor experto MetaTrader 5 «Martin 1» a la API de estrategia de alto nivel de StockSharp. El algoritmo mantiene continuamente exposición y usa pasos de martingala de estilo cobertura para recuperar drawdowns mientras realiza pirámide en tendencias rentables.

Lógica de trading

  1. Exposición inicial – cuando la estrategia está plana, abre inmediatamente una posición en la dirección definida por StartDirection, independientemente del filtro de tiempo. El tamaño base de la orden se toma de InitialVolume después de redondear al paso de volumen del instrumento.
  2. Filtro de ventana de tiempo – cuando UseTradingHours está habilitado, solo las acciones de escala (pirámide o cobertura) se permiten entre StartHour y EndHour inclusive, usando el tiempo del exchange contenido en las marcas de tiempo de las velas.
  3. Pirámide de ganadores – cada posición abierta se evalúa en cada vela terminada. Si la ganancia flotante de una posición larga supera la distancia de take-profit y permanece positiva, se envía una orden larga adicional con el volumen actual. Las posiciones cortas se comportan simétricamente. El precio de la nueva orden se asume como el cierre de la vela actual.
  4. Martingala de cobertura – cuando la dirección inicial es larga y una posición larga pierde más de (StopLossPips × tamaño de pip × (índice de multiplicación + 1)), la estrategia abre una orden corta opuesta. Antes de colocar la cobertura, el volumen se multiplica por LotMultiplier, se redondea al paso permitido, y el contador de multiplicación aumenta. La misma lógica se aplica a la inversa para la dirección inicial corta. La cobertura se detiene una vez que se alcanzan los pasos de MaxMultiplications.
  5. Objetivo de ganancia global – la ganancia no realizada en todas las posiciones restantes (convertida a dinero usando PriceStep/StepPrice) se suma. Si supera MinProfit, cada posición abierta se cierra emitiendo una orden de mercado en la dirección opuesta, y el estado de la martingala se reinicia.

Gestión de riesgos y dinero

  • El tamaño del pip se calcula a partir del paso de precio del instrumento. Las cotizaciones de tres y cinco dígitos multiplican el paso por diez para emular el ajuste de pip original de MetaTrader.
  • Los volúmenes se redondean hacia abajo al VolumeStep más cercano. Si el valor redondeado cae por debajo del paso, la orden se omite.
  • El contador de martingala y el volumen actual se reinician siempre que el libro quede plano, ya sea de forma natural o después de alcanzar el objetivo de ganancia global.
  • La estimación de ganancia ignora comisiones y swaps, reflejando el comportamiento del script original que dependía puramente del PnL flotante.

Parámetros

Nombre Descripción Predeterminado
CandleType Tipo de vela que impulsa todos los cálculos. Marco temporal de 1 minuto
UseTradingHours Habilita o deshabilita el filtro de ventana de tiempo. true
StartHour Hora inclusiva cuando el filtro de tiempo permite nuevas acciones de escala. 2
EndHour Hora inclusiva cuando las acciones de escala se detienen. 21
LotMultiplier Factor aplicado al volumen actual antes de abrir una cobertura. 1.6
MaxMultiplications Número máximo de pasos de cobertura que pueden activarse. 5
StartDirection Dirección de la primera orden después de que la estrategia quede plana. Buy
MinProfit Ganancia flotante (en dinero) que fuerza el cierre de todas las posiciones. 1.5
InitialVolume Volumen base para la primera orden y estado de reinicio. 0.1
StopLossPips Distancia en pips que activa la siguiente cobertura de martingala. 40
TakeProfitPips Distancia en pips que activa una entrada de pirámide. 100

Notas de implementación

  • ProcessCandle usa el pipeline de suscripción de velas de alto nivel (SubscribeCandles().Bind(...)) y opera estrictamente en velas terminadas, cumpliendo con las pautas de la plataforma.
  • La exposición cubierta se rastrea internamente con dos listas FIFO para que la estrategia pueda emular el comportamiento de cobertura de MetaTrader incluso en cuentas de compensación.
  • La conversión de ganancia depende de Security.PriceStep y Security.StepPrice. Cuando esos valores no están disponibles, la diferencia en precio se multiplica directamente por el volumen negociado como alternativa.
  • La estrategia mantiene el trading continuamente; deshabilitar el filtro de tiempo o establecer horas amplias hará que el algoritmo se comporte como el asesor experto original siempre activo.
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>
/// Hedging martingale strategy converted from the MetaTrader script "Martin 1".
/// Adds pyramid entries in profit and opens opposite hedges with increased volume after drawdowns.
/// </summary>
public class Martin1Strategy : Strategy
{
	private sealed class PositionRecord
	{
		public decimal Volume { get; set; }
		public decimal EntryPrice { get; set; }
	}

	private readonly StrategyParam<DataType> _candleType;
	private readonly StrategyParam<bool> _useTradingHours;
	private readonly StrategyParam<int> _startHour;
	private readonly StrategyParam<int> _endHour;
	private readonly StrategyParam<decimal> _lotMultiplier;
	private readonly StrategyParam<int> _maxMultiplications;
	private readonly StrategyParam<Sides> _startDirection;
	private readonly StrategyParam<decimal> _minProfit;
	private readonly StrategyParam<decimal> _initialVolume;
	private readonly StrategyParam<int> _stopLossPips;
	private readonly StrategyParam<int> _takeProfitPips;

	private readonly List<PositionRecord> _longPositions = new();
	private readonly List<PositionRecord> _shortPositions = new();

	private decimal _currentVolume;
	private int _multiplicationCount;

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

	/// <summary>
	/// Enable the trading hour filter.
	/// </summary>
	public bool UseTradingHours
	{
		get => _useTradingHours.Value;
		set => _useTradingHours.Value = value;
	}

	/// <summary>
	/// Inclusive hour (exchange time) when the trading window opens.
	/// </summary>
	public int StartHour
	{
		get => _startHour.Value;
		set => _startHour.Value = value;
	}

	/// <summary>
	/// Inclusive hour (exchange time) when the trading window closes.
	/// </summary>
	public int EndHour
	{
		get => _endHour.Value;
		set => _endHour.Value = value;
	}

	/// <summary>
	/// Multiplier applied to the current volume after each hedging step.
	/// </summary>
	public decimal LotMultiplier
	{
		get => _lotMultiplier.Value;
		set => _lotMultiplier.Value = value;
	}

	/// <summary>
	/// Maximum number of hedging multiplications that can be triggered.
	/// </summary>
	public int MaxMultiplications
	{
		get => _maxMultiplications.Value;
		set => _maxMultiplications.Value = value;
	}

	/// <summary>
	/// Direction of the very first position that is opened when flat.
	/// </summary>
	public Sides StartDirection
	{
		get => _startDirection.Value;
		set => _startDirection.Value = value;
	}

	/// <summary>
	/// Minimum floating profit that triggers closing all positions.
	/// </summary>
	public decimal MinProfit
	{
		get => _minProfit.Value;
		set => _minProfit.Value = value;
	}

	/// <summary>
	/// Base order volume used for the initial trade.
	/// </summary>
	public decimal InitialVolume
	{
		get => _initialVolume.Value;
		set => _initialVolume.Value = value;
	}

	/// <summary>
	/// Stop-loss distance expressed in pips.
	/// </summary>
	public int StopLossPips
	{
		get => _stopLossPips.Value;
		set => _stopLossPips.Value = value;
	}

	/// <summary>
	/// Take-profit distance expressed in pips.
	/// </summary>
	public int TakeProfitPips
	{
		get => _takeProfitPips.Value;
		set => _takeProfitPips.Value = value;
	}

	/// <summary>
	/// Initializes a new instance of the <see cref="Martin1Strategy"/> class.
	/// </summary>
	public Martin1Strategy()
	{
		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
			.SetDisplay("Candle Type", "Timeframe used to evaluate conditions", "General");

		_useTradingHours = Param(nameof(UseTradingHours), false)
			.SetDisplay("Use Trading Hours", "Restrict entries to a time window", "General");

		_startHour = Param(nameof(StartHour), 2)
			.SetRange(0, 23)
			.SetDisplay("Start Hour", "Hour to start monitoring for new trades", "General");

		_endHour = Param(nameof(EndHour), 21)
			.SetRange(0, 23)
			.SetDisplay("End Hour", "Hour to stop opening hedges/pyramids", "General");

		_lotMultiplier = Param(nameof(LotMultiplier), 1.6m)
			.SetGreaterThanZero()
			.SetDisplay("Lot Multiplier", "Factor applied to volume after a loss", "Money Management")
			
			.SetOptimize(1.1m, 3m, 0.1m);

		_maxMultiplications = Param(nameof(MaxMultiplications), 5)
			.SetGreaterThanZero()
			.SetDisplay("Max Multiplications", "Maximum hedging steps", "Money Management")
			
			.SetOptimize(1, 10, 1);

		_startDirection = Param(nameof(StartDirection), Sides.Buy)
			.SetDisplay("Start Direction", "Side of the initial order", "Trading");

		_minProfit = Param(nameof(MinProfit), 1.5m)
			.SetDisplay("Min Profit", "Floating profit target to flatten", "Risk")
			
			.SetOptimize(0.5m, 10m, 0.5m);

		_initialVolume = Param(nameof(InitialVolume), 0.1m)
			.SetGreaterThanZero()
			.SetDisplay("Initial Volume", "Baseline order size", "Money Management");

		_stopLossPips = Param(nameof(StopLossPips), 400)
			.SetGreaterThanZero()
			.SetDisplay("Stop Loss (pips)", "Distance before hedging the opposite side", "Risk");

		_takeProfitPips = Param(nameof(TakeProfitPips), 1000)
			.SetGreaterThanZero()
			.SetDisplay("Take Profit (pips)", "Distance to pyramid in the same direction", "Risk");
	}

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

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

		_longPositions.Clear();
		_shortPositions.Clear();
		_multiplicationCount = 0;
		_currentVolume = AdjustVolume(InitialVolume);
	}

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

		if (UseTradingHours && StartHour >= EndHour)
			throw new InvalidOperationException("Start hour must be less than end hour when the filter is enabled.");

		_longPositions.Clear();
		_shortPositions.Clear();
		_multiplicationCount = 0;
		_currentVolume = AdjustVolume(InitialVolume);

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

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

		// No indicators to check.

		var closePrice = candle.ClosePrice;
		var totalProfit = CalculateOpenProfit(closePrice);
		var withinHours = !UseTradingHours || IsWithinTradingHours(candle.CloseTime);

		if (withinHours)
		{
			if (_longPositions.Count > 0)
				EvaluateLongPositions(closePrice);

			if (_shortPositions.Count > 0)
				EvaluateShortPositions(closePrice);
		}

		if (_longPositions.Count == 0 && _shortPositions.Count == 0)
		{
			ResetMartingale();
			OpenInitialPosition(closePrice);
			return;
		}

		if ((_longPositions.Count > 0 || _shortPositions.Count > 0) && totalProfit > MinProfit)
		{
			CloseAllPositions(closePrice);
			ResetMartingale();
		}
	}

	private void EvaluateLongPositions(decimal closePrice)
	{
		var takeProfitDistance = GetTakeProfitDistance();
		var stopLossDistance = GetStopLossDistance();
		var snapshot = _longPositions.ToArray();

		foreach (var position in snapshot)
		{
			var priceGain = closePrice - position.EntryPrice;
			var profit = ConvertPriceToMoney(priceGain, position.Volume);

			if (profit > 0m && priceGain > takeProfitDistance)
				ExecuteOrder(Sides.Buy, _currentVolume, closePrice);

			if (StartDirection == Sides.Buy && stopLossDistance > 0m)
			{
				var lossDistance = position.EntryPrice - closePrice;
				if (lossDistance > stopLossDistance * (_multiplicationCount + 1) &&
					_multiplicationCount + 1 <= MaxMultiplications)
				{
					var newVolume = AdjustVolume(_currentVolume * LotMultiplier);
					if (newVolume > 0m)
					{
						_multiplicationCount++;
						_currentVolume = newVolume;
						ExecuteOrder(Sides.Sell, newVolume, closePrice);
					}
				}
			}
		}
	}

	private void EvaluateShortPositions(decimal closePrice)
	{
		var takeProfitDistance = GetTakeProfitDistance();
		var stopLossDistance = GetStopLossDistance();
		var snapshot = _shortPositions.ToArray();

		foreach (var position in snapshot)
		{
			var priceGain = position.EntryPrice - closePrice;
			var profit = ConvertPriceToMoney(priceGain, position.Volume);

			if (profit > 0m && priceGain > takeProfitDistance)
				ExecuteOrder(Sides.Sell, _currentVolume, closePrice);

			if (StartDirection == Sides.Sell && stopLossDistance > 0m)
			{
				var lossDistance = closePrice - position.EntryPrice;
				if (lossDistance > stopLossDistance * (_multiplicationCount + 1) &&
					_multiplicationCount + 1 <= MaxMultiplications)
				{
					var newVolume = AdjustVolume(_currentVolume * LotMultiplier);
					if (newVolume > 0m)
					{
						_multiplicationCount++;
						_currentVolume = newVolume;
						ExecuteOrder(Sides.Buy, newVolume, closePrice);
					}
				}
			}
		}
	}

	private void OpenInitialPosition(decimal price)
	{
		var volume = _currentVolume;
		if (volume <= 0m)
			return;

		var side = StartDirection == Sides.Sell ? Sides.Sell : Sides.Buy;
		ExecuteOrder(side, volume, price);
	}

	private void CloseAllPositions(decimal price)
	{
		var longVolume = GetTotalVolume(_longPositions);
		if (longVolume > 0m)
		{
			ExecuteOrder(Sides.Sell, longVolume, price);
		}

		var shortVolume = GetTotalVolume(_shortPositions);
		if (shortVolume > 0m)
		{
			ExecuteOrder(Sides.Buy, shortVolume, price);
		}
	}

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

		Volume = volume;

		if (side == Sides.Buy)
			BuyMarket();
		else
			SellMarket();

		UpdatePositions(side, volume, price);
	}

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

		if (side == Sides.Buy)
		{
			var remaining = volume;
			var index = 0;
			while (remaining > 0m && index < _shortPositions.Count)
			{
				var position = _shortPositions[index];
				var qty = Math.Min(position.Volume, remaining);
				position.Volume -= qty;
				remaining -= qty;
				if (position.Volume <= 0m)
				{
					_shortPositions.RemoveAt(index);
					continue;
				}

				index++;
			}

			if (remaining > 0m)
			{
				_longPositions.Add(new PositionRecord
				{
					Volume = remaining,
					EntryPrice = price
				});
			}
		}
		else
		{
			var remaining = volume;
			var index = 0;
			while (remaining > 0m && index < _longPositions.Count)
			{
				var position = _longPositions[index];
				var qty = Math.Min(position.Volume, remaining);
				position.Volume -= qty;
				remaining -= qty;
				if (position.Volume <= 0m)
				{
					_longPositions.RemoveAt(index);
					continue;
				}

				index++;
			}

			if (remaining > 0m)
			{
				_shortPositions.Add(new PositionRecord
				{
					Volume = remaining,
					EntryPrice = price
				});
			}
		}
	}

	private decimal CalculateOpenProfit(decimal currentPrice)
	{
		var profit = 0m;

		foreach (var position in _longPositions)
		{
			var diff = currentPrice - position.EntryPrice;
			profit += ConvertPriceToMoney(diff, position.Volume);
		}

		foreach (var position in _shortPositions)
		{
			var diff = position.EntryPrice - currentPrice;
			profit += ConvertPriceToMoney(diff, position.Volume);
		}

		return profit;
	}

	private decimal ConvertPriceToMoney(decimal priceDifference, decimal volume)
	{
		var priceStep = Security?.PriceStep ?? 0m;
		var stepPrice = priceStep;

		if (priceStep <= 0m || stepPrice <= 0m)
			return priceDifference * volume;

		var steps = priceDifference / priceStep;
		return steps * stepPrice * volume;
	}

	private decimal GetStopLossDistance()
	{
		var pip = GetPipSize();
		return StopLossPips * pip;
	}

	private decimal GetTakeProfitDistance()
	{
		var pip = GetPipSize();
		return TakeProfitPips * pip;
	}

	private decimal GetPipSize()
	{
		var priceStep = Security?.PriceStep ?? 0m;
		if (priceStep <= 0m)
			return 1m;

		var step = priceStep;
		var digits = 0;
		while (step < 1m && digits < 10)
		{
			step *= 10m;
			digits++;
		}

		if (digits == 3 || digits == 5)
			return priceStep * 10m;

		return priceStep;
	}

	private decimal AdjustVolume(decimal volume)
	{
		if (volume <= 0m)
			return 0m;

		var step = Security?.VolumeStep ?? 0m;
		if (step > 0m)
		{
			volume = Math.Floor(volume / step) * step;
			if (volume < step)
				return 0m;
		}

		return volume;
	}

	private static decimal GetTotalVolume(List<PositionRecord> positions)
	{
		var total = 0m;
		foreach (var position in positions)
			total += position.Volume;
		return total;
	}

	private bool IsWithinTradingHours(DateTime time)
	{
		var hour = time.Hour;
		return hour >= StartHour && hour <= EndHour;
	}

	private void ResetMartingale()
	{
		_multiplicationCount = 0;
		_currentVolume = AdjustVolume(InitialVolume);
	}
}