Ver en GitHub

Estrategia New Martin

Descripción general

La Estrategia New Martin replica el asesor experto de MetaTrader "New Martin" ejecutando una cobertura martingala simétrica en ambos lados del mercado. La estrategia mantiene una posición larga e inicial corta abiertas en todo momento y reequilibra la cobertura cuando las medias móviles suavizadas rápida y lenta (SMMA) se cruzan. Cuando un lado de la cobertura está perdiendo, el algoritmo multiplica la exposición en ese lado y simultáneamente realiza las ganancias en la pierna más rentable. Las salidas de toma de ganancias reciclan la cobertura reabriendo el lado faltante y opcionalmente purgando tanto los mejores como los peores rendidores para mantener la rejilla compacta.

La implementación apunta a la API de alto nivel de StockSharp y espera un portafolio con soporte de cobertura para que las piernas larga y corta puedan coexistir. Las órdenes se envían como órdenes de mercado por simplicidad, reflejando la lógica MQL original donde se asume que los llenados son inmediatos.

Indicadores y Señales

  • SMMA rápida (longitud predeterminada 5): rastrea la dirección de precio a corto plazo.
  • SMMA lenta (longitud predeterminada 20): representa la tendencia dominante.
  • Detección de cruce: un cruce de las dos barras completadas anteriores activa la adición martingala en la pierna de peor rendimiento. La señal se limita a una vez por vela almacenando el tiempo de apertura de la vela del último cruce.

Gestión de Posiciones

  • Cobertura inicial: tan pronto como se forman los indicadores, la estrategia abre una posición larga y una corta con el volumen inicial configurado. Ambas operaciones usan una distancia de toma de ganancias simétrica en pips.
  • Reciclaje de toma de ganancias: cuando el precio toca el nivel de toma de ganancias de cualquier pierna, la estrategia cierra esa posición, registra el evento y opcionalmente cierra tanto las posiciones más rentables como las más perdedoras para realizar ganancias y pérdidas en pares. Los lados faltantes se reabren inmediatamente con el volumen base para que la cobertura permanezca equilibrada.
  • Promediación martingala: en cada cruce de SMMA, el algoritmo identifica la posición con el menor beneficio no realizado. Aumenta la exposición en ese lado multiplicando el volumen de la operación por el multiplicador martingala (predeterminado 1.6) después de ajustar al paso de volumen del instrumento. La posición abierta más rentable se cierra justo después de la operación de promediación para liberar el beneficio bloqueado.

Gestión de Riesgos

  • Protección contra caída de capital: se rastrea el capital de portafolio más alto observado. Si la caída desde ese pico supera el porcentaje configurado, todas las posiciones abiertas se liquidan y la inicialización de la cobertura se pospone hasta la siguiente vela.
  • Volumen base dinámico: cuando el capital crece al menos por el multiplicador martingala relativo al balance previamente registrado, el volumen de cobertura base se aumenta por el mismo multiplicador (respetando también los límites de volumen del exchange). Esto replica el comportamiento del EA original donde las ganancias se reinvierten para escalar la rejilla.
  • Normalización de volumen: cada volumen solicitado se redondea hacia abajo al paso de volumen del exchange y se limita entre el volumen mínimo y máximo del instrumento para evitar rechazos de órdenes.

Parámetros

  • Take Profit (pips): distancia desde el precio de entrada para colocar el objetivo de toma de ganancias para cada pierna. Predeterminado 50 pips.
  • Initial Volume: volumen base por lado de la cobertura. Predeterminado 0.1 contratos.
  • Slow MA / Fast MA: longitudes de los indicadores SMMA lento y rápido (predeterminados 20 y 5). El período lento debe permanecer mayor que el período rápido.
  • Equity DD %: caída máxima permitida desde el pico de capital antes de que todas las posiciones se cierren. Predeterminado 12%.
  • Multiplier: factor martingala usado para promediar hacia abajo y para escalar el volumen base después de un crecimiento de capital importante. Predeterminado 1.6.
  • Candle Type: marco temporal de las velas usadas para los cálculos. Predeterminado velas de 15 minutos, pero puede cambiarse para coincidir con el marco temporal del gráfico del EA original.

Notas

  • La estrategia requiere cuentas habilitadas para cobertura porque mantiene posiciones largas y cortas abiertas simultáneamente.
  • Se usan órdenes de mercado para entradas y salidas, al igual que el experto MQL que dependía de llenados instantáneos. Adapte la lógica de órdenes si se necesita control de deslizamiento.
  • Asegúrese de que los metadatos del instrumento (paso de precio, paso de volumen, volumen mínimo/máximo) estén correctamente configurados para que la normalización de volumen funcione como se espera.
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>
/// Hedged martingale strategy that scales positions on moving average crossovers.
/// </summary>
public class NewMartinStrategy : Strategy
{
	private readonly StrategyParam<decimal> _takeProfitPips;
	private readonly StrategyParam<decimal> _initialVolume;
	private readonly StrategyParam<int> _slowPeriod;
	private readonly StrategyParam<int> _fastPeriod;
	private readonly StrategyParam<decimal> _lossPercent;
	private readonly StrategyParam<decimal> _multiplier;
	private readonly StrategyParam<DataType> _candleType;

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

	private SmoothedMovingAverage _slowMa;
	private SmoothedMovingAverage _fastMa;

	private decimal? _slowPrev1;
	private decimal? _slowPrev2;
	private decimal? _fastPrev1;
	private decimal? _fastPrev2;

	private decimal _currentVolume;
	private decimal _pipSize;
	private decimal _startBalance;
	private decimal _peakBalance;
	private DateTimeOffset? _lastCrossTime;
	private bool _positionsInitialized;

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

	/// <summary>
	/// Initial hedge volume per side.
	/// </summary>
	public decimal InitialVolume
	{
		get => _initialVolume.Value;
		set => _initialVolume.Value = value;
	}

	/// <summary>
	/// Period of the slow smoothed moving average.
	/// </summary>
	public int SlowPeriod
	{
		get => _slowPeriod.Value;
		set => _slowPeriod.Value = value;
	}

	/// <summary>
	/// Period of the fast smoothed moving average.
	/// </summary>
	public int FastPeriod
	{
		get => _fastPeriod.Value;
		set => _fastPeriod.Value = value;
	}

	/// <summary>
	/// Maximum equity drawdown percentage before all positions are liquidated.
	/// </summary>
	public decimal LossPercent
	{
		get => _lossPercent.Value;
		set => _lossPercent.Value = value;
	}

	/// <summary>
	/// Multiplier applied to the martingale additions and base volume growth.
	/// </summary>
	public decimal Multiplier
	{
		get => _multiplier.Value;
		set => _multiplier.Value = value;
	}

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

	/// <summary>
	/// Initializes a new instance of <see cref="NewMartinStrategy"/>.
	/// </summary>
	public NewMartinStrategy()
	{
		_takeProfitPips = Param(nameof(TakeProfitPips), 50m)
		.SetGreaterThanZero()
		.SetDisplay("Take Profit", "Target distance in pips", "Risk")
		
		.SetOptimize(10m, 200m, 10m);

		_initialVolume = Param(nameof(InitialVolume), 0.1m)
		.SetGreaterThanZero()
		.SetDisplay("Initial Volume", "Volume per hedge side", "Trading")
		
		.SetOptimize(0.01m, 1m, 0.01m);

		_slowPeriod = Param(nameof(SlowPeriod), 20)
		.SetGreaterThanZero()
		.SetDisplay("Slow MA", "Slow smoothed MA period", "Indicators")
		
		.SetOptimize(10, 80, 5);

		_fastPeriod = Param(nameof(FastPeriod), 5)
		.SetGreaterThanZero()
		.SetDisplay("Fast MA", "Fast smoothed MA period", "Indicators")
		
		.SetOptimize(2, 20, 1);

		_lossPercent = Param(nameof(LossPercent), 12m)
		.SetGreaterThanZero()
		.SetDisplay("Equity DD %", "Maximum drawdown before reset", "Risk")
		
		.SetOptimize(5m, 30m, 1m);

		_multiplier = Param(nameof(Multiplier), 1.6m)
		.SetGreaterThanZero()
		.SetDisplay("Multiplier", "Martingale growth factor", "Trading")
		
		.SetOptimize(1.1m, 3m, 0.1m);

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(1).TimeFrame())
		.SetDisplay("Candle Type", "Time frame for calculations", "General");
	}

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

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

		_longPositions.Clear();
		_shortPositions.Clear();
		_slowPrev1 = null;
		_slowPrev2 = null;
		_fastPrev1 = null;
		_fastPrev2 = null;
		_currentVolume = 0m;
		_pipSize = 0m;
		_startBalance = 0m;
		_peakBalance = 0m;
		_lastCrossTime = null;
		_positionsInitialized = false;
	}

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

		if (SlowPeriod <= FastPeriod)
			throw new InvalidOperationException("Slow period must be greater than fast period.");

		_currentVolume = AdjustVolume(InitialVolume);

		var step = Security?.PriceStep ?? 0m;
		if (step <= 0m)
		{
			_pipSize = 1m;
		}
		else
		{
			_pipSize = step;
			var decimals = Security?.Decimals ?? 0;
			if (decimals == 3 || decimals == 5)
				_pipSize = step * 10m;
		}

		_slowMa = new SmoothedMovingAverage { Length = SlowPeriod };
		_fastMa = new SmoothedMovingAverage { Length = FastPeriod };

		var subscription = SubscribeCandles(CandleType);
		subscription
		.Bind(_slowMa, _fastMa, ProcessCandle)
		.Start();

		_startBalance = Portfolio?.CurrentValue ?? Portfolio?.BeginValue ?? 0m;
		_peakBalance = _startBalance;
	}

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

		if (!_slowMa.IsFormed || !_fastMa.IsFormed)
		{
			UpdateAverageHistory(slow, fast);
			return;
		}

		UpdateAccountMetrics();

		if (ShouldCloseAllPositions())
		{
			CloseAllPositions();
			_positionsInitialized = false;
		}

		if (!_positionsInitialized)
		{
			InitializeHedge(candle.ClosePrice);
		}

		var tpTriggered = CheckTakeProfits(candle);

		if (tpTriggered)
		{
			CloseExtremePositions(candle.ClosePrice);
		}

		if (_longPositions.Count == 0)
			OpenPosition(Sides.Buy, _currentVolume, candle.ClosePrice);

		if (_shortPositions.Count == 0)
			OpenPosition(Sides.Sell, _currentVolume, candle.ClosePrice);

		HandleCrossing(candle, slow, fast);

		UpdateAverageHistory(slow, fast);
	}

	private void InitializeHedge(decimal price)
	{
		if (_currentVolume <= 0m)
			return;

		// Start with symmetric hedge on both sides.
		OpenPosition(Sides.Buy, _currentVolume, price);
		OpenPosition(Sides.Sell, _currentVolume, price);
		_positionsInitialized = _longPositions.Count > 0 && _shortPositions.Count > 0;
	}

	private void UpdateAccountMetrics()
	{
		var equity = Portfolio?.CurrentValue ?? 0m;

		if (equity > _peakBalance)
			_peakBalance = equity;

		if (_startBalance > 0m && equity >= _startBalance * Multiplier)
		{
			_startBalance = equity;

			var newVolume = AdjustVolume(_currentVolume * Multiplier);
			if (newVolume > 0m)
				_currentVolume = newVolume;
		}
	}

	private bool ShouldCloseAllPositions()
	{
		if (_peakBalance <= 0m)
			return false;

		var equity = Portfolio?.CurrentValue ?? 0m;
		if (equity <= 0m)
			return false;

		var drawdown = (_peakBalance - equity) / _peakBalance * 100m;
		return drawdown >= LossPercent;
	}

	private bool CheckTakeProfits(ICandleMessage candle)
	{
		var triggered = false;
		var offset = TakeProfitPips * _pipSize;
		if (offset <= 0m)
			return false;

		foreach (var entry in _longPositions.ToArray())
		{
			if (entry is null)
				continue;

			if (candle.HighPrice >= entry.TakeProfit)
			{
				CloseEntry(entry);
				triggered = true;
			}
		}

		foreach (var entry in _shortPositions.ToArray())
		{
			if (entry is null)
				continue;

			if (candle.LowPrice <= entry.TakeProfit)
			{
				CloseEntry(entry);
				triggered = true;
			}
		}

		return triggered;
	}

	private void CloseExtremePositions(decimal price)
	{
		var (lossEntry, lossValue, profitEntry, profitValue) = GetExtremePositions(price);

		if (lossEntry is not null && lossValue < 0m)
			CloseEntry(lossEntry);

		if (profitEntry is not null && profitEntry != lossEntry)
			CloseEntry(profitEntry);
	}

	private void HandleCrossing(ICandleMessage candle, decimal slow, decimal fast)
	{
		if (!_slowPrev2.HasValue || !_slowPrev1.HasValue || !_fastPrev2.HasValue || !_fastPrev1.HasValue)
			return;

		var crossDetected = (_slowPrev2.Value > _fastPrev2.Value && _slowPrev1.Value < _fastPrev1.Value)
		|| (_slowPrev2.Value < _fastPrev2.Value && _slowPrev1.Value > _fastPrev1.Value);

		if (!crossDetected)
			return;

		if (_lastCrossTime == candle.OpenTime)
			return;

		_lastCrossTime = candle.OpenTime;

		var (lossEntry, _, profitEntry, _) = GetExtremePositions(candle.ClosePrice);
		if (lossEntry is null)
			return;

		var volume = AdjustVolume(lossEntry.Volume * Multiplier);
		if (volume <= 0m)
			return;

		// Average down on the weakest side.
		OpenPosition(lossEntry.Side, volume, candle.ClosePrice);

		if (profitEntry is not null && profitEntry != lossEntry)
		{
			// Lock in profit on the strongest position after the new hedge.
			CloseEntry(profitEntry);
		}
	}

	private void UpdateAverageHistory(decimal slow, decimal fast)
	{
		_slowPrev2 = _slowPrev1;
		_slowPrev1 = slow;
		_fastPrev2 = _fastPrev1;
		_fastPrev1 = fast;
	}

	private void OpenPosition(Sides side, decimal requestedVolume, decimal price)
	{
		var volume = AdjustVolume(requestedVolume);
		if (volume <= 0m)
			return;

		var offset = TakeProfitPips * _pipSize;
		if (offset <= 0m)
			return;

		var takeProfit = side == Sides.Buy ? price + offset : price - offset;
		var entry = new PositionEntry(side, volume, price, takeProfit);

		if (side == Sides.Buy)
		{
			_longPositions.Add(entry);
			BuyMarket(volume);
		}
		else
		{
			_shortPositions.Add(entry);
			SellMarket(volume);
		}
	}

	private void CloseAllPositions()
	{
		foreach (var entry in _longPositions.ToArray())
			CloseEntry(entry);

		foreach (var entry in _shortPositions.ToArray())
			CloseEntry(entry);
	}

	private void CloseEntry(PositionEntry entry)
	{
		if (entry.Side == Sides.Buy)
		{
			SellMarket(entry.Volume);

			for (var i = _longPositions.Count - 1; i >= 0; i--)
			{
				if (_longPositions[i] == entry)
				{
					_longPositions.RemoveAt(i);
					break;
				}
			}
		}
		else
		{
			BuyMarket(entry.Volume);

			for (var i = _shortPositions.Count - 1; i >= 0; i--)
			{
				if (_shortPositions[i] == entry)
				{
					_shortPositions.RemoveAt(i);
					break;
				}
			}
		}
	}

	private (PositionEntry lossEntry, decimal lossValue, PositionEntry profitEntry, decimal profitValue) GetExtremePositions(decimal price)
	{
		PositionEntry lossEntry = null;
		PositionEntry profitEntry = null;
		var lossValue = 0m;
		var profitValue = 0m;

		foreach (var entry in _longPositions)
		{
			var pnl = (price - entry.EntryPrice) * entry.Volume;
			if (lossEntry is null || pnl < lossValue)
			{
				lossEntry = entry;
				lossValue = pnl;
			}

			if (profitEntry is null || pnl > profitValue)
			{
				profitEntry = entry;
				profitValue = pnl;
			}
		}

		foreach (var entry in _shortPositions)
		{
			var pnl = (entry.EntryPrice - price) * entry.Volume;
			if (lossEntry is null || pnl < lossValue)
			{
				lossEntry = entry;
				lossValue = pnl;
			}

			if (profitEntry is null || pnl > profitValue)
			{
				profitEntry = entry;
				profitValue = pnl;
			}
		}

		return (lossEntry, lossValue, profitEntry, profitValue);
	}

	private decimal AdjustVolume(decimal volume)
	{
		var security = Security;
		if (security is null)
			return volume;

		var min = security.MinVolume ?? 0m;
		if (min > 0m && volume < min)
			return 0m;

		var max = security.MaxVolume ?? 0m;
		if (max > 0m && volume > max)
			volume = max;

		return volume;
	}

	private sealed record PositionEntry(Sides Side, decimal Volume, decimal EntryPrice, decimal TakeProfit);
}