Ver en GitHub

Estrategia mínima de Frank Ud

Este ejemplo traslada el asesor experto clásico Frank Ud MetaTrader a StockSharp utilizando la estrategia de alto nivel API. El script original MQL ejecuta una cuadrícula de martingala cubierta que sigue agregando posiciones cada vez que el precio se mueve con respecto a la última entrada. Las ganancias se bloquean una vez que la orden más reciente (y por lo tanto la más grande) gana una cantidad fija de pips, después de lo cual todas las operaciones de ese lado se cierran simultáneamente.

Lógica central

  1. Cobertura simétrica. La estrategia mantiene dos escaleras independientes de posiciones de mercado: una escalera larga y una escalera corta. Por lo tanto, es posible mantener posiciones largas y cortas al mismo tiempo, como en el modo de cobertura de MetaTrader.
  2. Martingale progresión. El primer pedido en cualquier lado usa InitialVolume (por defecto, 0,1 lotes). Cada entrada posterior en el mismo lado duplica el mayor volumen abierto actualmente. Todo lote que la estrategia envía —incluido el primero— se ajusta después a lo que el instrumento admite realmente: se redondea a la baja hasta un número entero de unidades de VolumeStep, se eleva a MinVolume si queda por debajo y se limita a MaxVolume. Las restricciones que el instrumento no informa se omiten.
  3. Espaciado de entrada. Se agrega una nueva posición solo cuando el precio se ha movido al menos ReEntryPips (predeterminado 41 pips) más allá del mejor precio de entrada de la escalera existente. La escalera larga espera a que los precios de venta caigan por debajo de lowest_buy - ReEntryPips, mientras que la escalera corta espera a que los precios de oferta suban por encima de highest_sell + ReEntryPips. Ambos lados de la cotización se toman del cierre de la misma vela, de modo que en esta portación las dos comparaciones se hacen contra el mismo precio.
  4. Recolección de ganancias. Para cada escalera, la operación con el mayor volumen actúa como la orden "desencadenante". Cuando su beneficio excede TakeProfitPips (65 pips predeterminado), o cuando el precio alcanza el objetivo con margen situado a TakeProfitPips + ExtraTakeProfitPips pips de esa entrada, cada posición en ese lado se aplana con una única orden de mercado y la escalera se vacía.
  5. Protección de margen. Antes de enviar una nueva entrada, la estrategia verifica que el margen libre de la cartera —su valor actual menos la comisión que informa— se mantenga por encima de Balance × MinimumFreeMarginRatio (predeterminado 0,5). La protección cubre ambas escaleras y todas sus entradas, incluida la primera. Fijar la relación en cero la desactiva, y lo mismo ocurre si la cartera no devuelve valor alguno: en ambos casos la verificación simplemente se supera y la estrategia vuelve al comportamiento de volumen fijo del experto original.

Parámetros

Parámetro Descripción
TakeProfitPips Umbral de beneficio de pip medido en el pedido más grande y más reciente. Una vez superado, se cierran todas las posiciones de ese lado.
ReEntryPips Distancia mínima de pips entre la mejor entrada existente y la oferta/demanda actual antes de que se agregue una nueva orden de martingala.
InitialVolume Tamaño de lote base para el primer pedido de cada escalera. Los pedidos posteriores duplican el mayor volumen activo.
MinimumFreeMarginRatio Relación requerida entre margen libre y saldo antes de que se permitan nuevas entradas. Establezca en 0 para desactivar la verificación. Valor predeterminado 0,5.
ExtraTakeProfitPips Distancia adicional en pips que se suma a TakeProfitPips al calcular el objetivo de salida con margen. Valor predeterminado 25.
CandleType Serie de velas a la que se suscribe la estrategia. Valor predeterminado: marco temporal de 1 minuto.

Notas de implementación

  • Un pip no es el paso de precio en bruto. En la primera vela cerrada que procesa, la estrategia fija un pip en una diezmilésima parte del precio cotizado, lo limita por abajo al paso de precio del instrumento (para que nunca sea más fino de lo que el instrumento realmente negocia) y después conserva ese valor durante el resto de la ejecución, de modo que la cuadrícula no se desplace bajo sus propios pies. Así se reproduce la convención de forex para la que se escribió el experto (0,0001 en EURUSD a 1,10; 0,01 en USDJPY a 150) y las distancias siguen siendo significativas en un instrumento cotizado con cinco cifras, donde el paso en bruto de 0,01 alcanzaría un objetivo de 65 pips en casi cada vela. Si el instrumento no informa un paso de precio, el pip queda definido únicamente por esa fracción.
  • La estrategia se basa en velas cerradas, no en cotizaciones de nivel 1. Se suscribe a la serie CandleType (marco temporal de 1 minuto de forma predeterminada) e ignora toda vela que aún no esté cerrada. El historial incluido no trae libro de órdenes, por lo que el cierre de la vela cerrada hace las veces tanto de oferta como de demanda. Las implementaciones en C# y en Python se suscriben exactamente de la misma manera.
  • La entrada en la escalera se registra en el momento en que se envía la orden, no cuando se ejecuta: al abrir se añaden a la lista el cierre de la vela y el volumen solicitado, y al cerrar se envía una única orden de mercado por todo el volumen de la escalera y la lista se vacía. No se mantiene ningún diccionario de intenciones de órdenes ni se usa una devolución de llamada de ejecución: en este emulador la ejecución llega de forma síncrona dentro del registro de la orden, antes de que la orden pudiera siquiera escribirse en semejante diccionario.
  • La contabilidad de posiciones almacena cada entrada de la escalera (precio y volumen) en listas simples en lugar de consultar estadísticas acumulativas, preservando el comportamiento de las matrices MQL que se utilizaron para localizar el lote más grande y su precio de entrada.
  • El buffer adicional en pips que el experto original colocó en cada orden de toma de ganancias se expone como el parámetro ExtraTakeProfitPips (25 pips de forma predeterminada) y se retiene como condición de salida adicional.

Las implementaciones están disponibles en C# y Python.

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>
/// Minimal port of the Frank Ud averaging expert from MetaTrader.
/// The strategy opens hedged martingale grids and liquidates both sides
/// once the newest position reaches the configured profit in pips.
/// </summary>
public class FrankUdMinimalStrategy : Strategy
{
	// Forex convention this expert came from: one pip is roughly a ten-thousandth of the quoted
	// price (0.0001 on EURUSD at 1.10, 0.01 on USDJPY at 150). Expressing it as a fraction of the
	// price keeps the same grid spacing on instruments quoted in five figures.
	private const decimal _pipFraction = 0.0001m;

	private readonly StrategyParam<decimal> _takeProfitPips;
	private readonly StrategyParam<decimal> _reEntryPips;
	private readonly StrategyParam<decimal> _initialVolume;
	private readonly StrategyParam<decimal> _minimumFreeMarginRatio;
	private readonly StrategyParam<decimal> _extraTakeProfitPips;
	private readonly StrategyParam<DataType> _candleType;

	private readonly List<PositionEntry> _longEntries = new();
	private readonly List<PositionEntry> _shortEntries = new();

	private decimal _pointValue;
	private decimal _takeProfitThreshold;
	private decimal _takeProfitDistance;
	private decimal _reEntryDistance;
	private decimal _baseVolume;
	private decimal _lastBid;
	private decimal _lastAsk;

	/// <summary>
	/// Creates a new instance of <see cref="FrankUdMinimalStrategy"/> with default parameters.
	/// </summary>
	public FrankUdMinimalStrategy()
	{
		_takeProfitPips = Param(nameof(TakeProfitPips), 65m)
		.SetDisplay("Profit trigger (pips)", "Pip profit that forces an exit of all positions.", "Risk")
		.SetGreaterThanZero();

		_reEntryPips = Param(nameof(ReEntryPips), 41m)
		.SetDisplay("Re-entry distance (pips)", "Pip distance required before adding the next grid order.", "Grid")
		.SetGreaterThanZero();

		_initialVolume = Param(nameof(InitialVolume), 0.1m)
		.SetDisplay("Initial volume", "Base lot used for the very first order.", "Risk")
		.SetGreaterThanZero();

		_minimumFreeMarginRatio = Param(nameof(MinimumFreeMarginRatio), 0.5m)
		.SetDisplay("Free margin ratio", "Free margin must stay above Balance × Ratio before adding orders.", "Risk")
		.SetNotNegative();

		_extraTakeProfitPips = Param(nameof(ExtraTakeProfitPips), 25m)
		.SetDisplay("Buffer profit (pips)", "Additional pip distance applied when calculating buffered targets.", "Risk")
		.SetNotNegative();

		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(1).TimeFrame())
		.SetDisplay("Candle type", "Candle series the grid reacts to.", "General");
}

	/// <summary>
	/// Profit threshold expressed in pips.
	/// </summary>
	public decimal TakeProfitPips
	{
		get => _takeProfitPips.Value;
		set => _takeProfitPips.Value = value;
	}

	/// <summary>
	/// Distance in pips between consecutive martingale entries.
	/// </summary>
	public decimal ReEntryPips
	{
		get => _reEntryPips.Value;
		set => _reEntryPips.Value = value;
	}

	/// <summary>
	/// Base lot volume for the very first order.
	/// </summary>
	public decimal InitialVolume
	{
		get => _initialVolume.Value;
		set => _initialVolume.Value = value;
	}

	/// <summary>
	/// Minimal free margin ratio required to send new orders.
	/// </summary>
	public decimal MinimumFreeMarginRatio
{
		get => _minimumFreeMarginRatio.Value;
		set => _minimumFreeMarginRatio.Value = value;
}

	/// <summary>
	/// Additional pip buffer added to the take-profit distance.
	/// </summary>
	public decimal ExtraTakeProfitPips
	{
		get => _extraTakeProfitPips.Value;
		set => _extraTakeProfitPips.Value = value;
	}

	/// <summary>
	/// Candle series the grid reacts to.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

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

		_longEntries.Clear();
		_shortEntries.Clear();

		_pointValue = 0m;
		_takeProfitThreshold = 0m;
		_takeProfitDistance = 0m;
		_reEntryDistance = 0m;
		_baseVolume = 0m;
		_lastBid = 0m;
		_lastAsk = 0m;
	}

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

		// The pip and the distances derived from it need a quote, so they are set up on the first one.
		_takeProfitThreshold = TakeProfitPips;
		_baseVolume = AdjustVolume(InitialVolume);

		SubscribeCandles(CandleType)
		.Bind(ProcessCandle)
		.Start();
	}

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

		// The bundled history carries no book, so the close stands for both sides of the quote.
		_lastBid = candle.ClosePrice;
		_lastAsk = candle.ClosePrice;

		if (_lastBid <= 0m || _lastAsk <= 0m)
		return;

		if (!TryInitializePip())
		return;

		if (ShouldCloseLong())
		CloseLongPositions();

		if (ShouldCloseShort())
		CloseShortPositions();

		if (ShouldOpenLong())
		OpenLongPosition();

		if (ShouldOpenShort())
		OpenShortPosition();
	}

	private bool TryInitializePip()
	{
		if (_pointValue > 0m)
		return true;

		var reference = (_lastBid + _lastAsk) / 2m;
		if (reference <= 0m)
		return false;

		// A missing or zero price step simply leaves the pip unfloored; the fraction alone already
		// keeps it positive.
		var floor = Security?.PriceStep is decimal step && step > 0m ? step : 0m;

		// Frozen for the rest of the run: a pip that followed the price would move the grid under itself.
		_pointValue = Math.Max(reference * _pipFraction, floor);
		_takeProfitDistance = (TakeProfitPips + ExtraTakeProfitPips) * _pointValue;
		_reEntryDistance = ReEntryPips * _pointValue;

		return true;
	}

	private bool ShouldCloseLong()
	{
		if (_longEntries.Count == 0)
		return false;

		var entry = GetMaxVolumeEntry(_longEntries);
		if (entry == null)
		return false;

		var profitPips = (_lastBid - entry.Price) / _pointValue;
		var bufferedTarget = entry.Price + _takeProfitDistance;
		var reachedBufferedTarget = _takeProfitDistance > 0m && _lastBid >= bufferedTarget;

		return profitPips > _takeProfitThreshold || reachedBufferedTarget;
	}

	private bool ShouldCloseShort()
	{
		if (_shortEntries.Count == 0)
		return false;

		var entry = GetMaxVolumeEntry(_shortEntries);
		if (entry == null)
		return false;

		var profitPips = (entry.Price - _lastAsk) / _pointValue;
		var bufferedTarget = entry.Price - _takeProfitDistance;
		var reachedBufferedTarget = _takeProfitDistance > 0m && _lastAsk <= bufferedTarget;

		return profitPips > _takeProfitThreshold || reachedBufferedTarget;
	}

	private bool ShouldOpenLong()
	{
		if (_baseVolume <= 0m)
		return false;

		if (!HasEnoughMargin())
		return false;

		if (_longEntries.Count == 0)
		return true;

		var lowestPrice = GetExtremePrice(_longEntries, true);
		return lowestPrice - _reEntryDistance > _lastAsk;
	}

	private bool ShouldOpenShort()
	{
		if (_baseVolume <= 0m)
		return false;

		if (!HasEnoughMargin())
		return false;

		if (_shortEntries.Count == 0)
		return true;

		var highestPrice = GetExtremePrice(_shortEntries, false);
		return highestPrice + _reEntryDistance < _lastBid;
	}

	private void OpenLongPosition()
	{
		var volume = DetermineNextVolume(_longEntries);
		if (volume <= 0m)
		return;

		BuyMarket(volume);
		AddEntry(_longEntries, _lastAsk, volume);
	}

	private void OpenShortPosition()
	{
		var volume = DetermineNextVolume(_shortEntries);
		if (volume <= 0m)
		return;

		SellMarket(volume);
		AddEntry(_shortEntries, _lastBid, volume);
	}

	private void CloseLongPositions()
	{
		var volume = GetTotalVolume(_longEntries);
		if (volume <= 0m)
		return;

		SellMarket(volume);
		_longEntries.Clear();
	}

	private void CloseShortPositions()
	{
		var volume = GetTotalVolume(_shortEntries);
		if (volume <= 0m)
		return;

		BuyMarket(volume);
		_shortEntries.Clear();
	}

	private decimal DetermineNextVolume(List<PositionEntry> entries)
	{
		if (_baseVolume <= 0m)
		return 0m;

		var volume = entries.Count == 0
		? _baseVolume
		: GetMaxVolume(entries) * 2m;

		return AdjustVolume(volume);
	}

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

		var security = Security;

		if (security?.VolumeStep is decimal step && step > 0m)
		{
			var steps = Math.Floor(volume / step);
			volume = steps * step;
		}

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

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

		return volume;
	}

	private bool HasEnoughMargin()
	{
		if (MinimumFreeMarginRatio <= 0m)
		return true;

		var portfolio = Portfolio;
		if (portfolio == null)
		return true;

		var balance = portfolio.CurrentValue ?? portfolio.BeginValue ?? 0m;
		if (balance <= 0m)
		return true;

		var blocked = portfolio.Commission ?? 0m;
		var baseValue = portfolio.CurrentValue ?? portfolio.BeginValue;
		if (baseValue == null)
		return true;

		var freeMargin = baseValue.Value - blocked;
		return freeMargin > balance * MinimumFreeMarginRatio;
	}

	private static void AddEntry(List<PositionEntry> entries, decimal price, decimal volume)
	{
		if (volume <= 0m)
		return;

		entries.Add(new PositionEntry(price, volume));
	}

	private static decimal GetTotalVolume(List<PositionEntry> entries)
	{
		decimal total = 0m;

		foreach (var entry in entries)
		total += entry.Volume;

		return total;
	}

	private static PositionEntry GetMaxVolumeEntry(List<PositionEntry> entries)
	{
		PositionEntry result = null;
		decimal maxVolume = 0m;

		foreach (var entry in entries)
		{
			if (entry.Volume > maxVolume)
			{
				maxVolume = entry.Volume;
				result = entry;
			}
		}

		return result;
	}

	private static decimal GetMaxVolume(List<PositionEntry> entries)
	{
		decimal maxVolume = 0m;

		foreach (var entry in entries)
		if (entry.Volume > maxVolume)
		maxVolume = entry.Volume;

		return maxVolume;
	}

	private static decimal GetExtremePrice(List<PositionEntry> entries, bool isLong)
	{
		var hasValue = false;
		decimal result = 0m;

		foreach (var entry in entries)
		{
			var price = entry.Price;

			if (!hasValue)
			{
				result = price;
				hasValue = true;
				continue;
			}

			if (isLong)
			{
				if (price < result)
				result = price;
			}
			else if (price > result)
			{
				result = price;
			}
		}

		return result;
	}

	private sealed class PositionEntry
	{
		public PositionEntry(decimal price, decimal volume)
		{
			Price = price;
			Volume = volume;
		}

		public decimal Price { get; }

		public decimal Volume { get; }
	}
}