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. Los ajustes de volumen respetan las restricciones MinVolume, MaxVolume y VolumeStep del instrumento.
  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.
  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 toca el nivel de obtención de beneficios implícito (TakeProfitPips + 25) utilizado por la versión MQL, cada posición en ese lado se aplana con una única orden de mercado.
  5. Protección de margen. Antes de enviar cualquier entrada nueva, la estrategia verifica que el margen libre informado por la cartera (CurrentValue - BlockedValue) se mantenga por encima de Balance × MinimumFreeMarginRatio (predeterminado 0,5). Si el corredor no informa las estadísticas de la cartera, la verificación 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.

Notas de implementación

  • La estrategia se basa únicamente en cotizaciones de nivel 1: las actualizaciones de ofertas impulsan la lógica de escalera corta y las actualizaciones de solicitudes impulsan la lógica de escalera larga.
  • Las intenciones de los pedidos se rastrean en un diccionario interno para que OnNewMyTrade sepa si un llenado abrió o cerró una escalera. Esto imita la contabilidad de boletos explícita en la fuente MQL.
  • La contabilidad de posiciones almacena cada llenado (precio y volumen) en listas 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 de 25 pips que el experto original colocó en cada orden de toma de ganancias se retiene como condición de salida adicional.

Nota: El puerto Python se omite intencionalmente por ahora, según lo solicitado. La carpeta contiene sólo la implementación de C# y la documentación multilingüe.

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
{
	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 List<PositionEntry> _longEntries = new();
	private readonly List<PositionEntry> _shortEntries = new();
	private readonly Dictionary<long, OrderActions> _orderActions = 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")
		.SetGreaterThanZero();

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

	/// <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;
	}

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

		_longEntries.Clear();
		_shortEntries.Clear();
		_orderActions.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);

		var security = Security ?? throw new InvalidOperationException("Security is not assigned.");
		var priceStep = security.PriceStep ?? 0.01m;

		_pointValue = priceStep;
		_takeProfitThreshold = TakeProfitPips;
		_takeProfitDistance = (TakeProfitPips + ExtraTakeProfitPips) * _pointValue;
		_reEntryDistance = ReEntryPips * _pointValue;
		_baseVolume = AdjustVolume(InitialVolume);

		var l1sub = new Subscription(DataType.Level1, Security);
		l1sub.MarketData.BuildField = Level1Fields.BestBidPrice;
		SubscribeLevel1(l1sub)
		.Bind(ProcessLevel1)
		.Start();
	}

	private void ProcessLevel1(Level1ChangeMessage message)
	{
		if (message.Changes.TryGetValue(Level1Fields.BestBidPrice, out var bidPrice))
		_lastBid = (decimal)bidPrice;

		if (message.Changes.TryGetValue(Level1Fields.BestAskPrice, out var askPrice))
		_lastAsk = (decimal)askPrice;

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

		if (ShouldCloseLong())
		CloseLongPositions();

		if (ShouldCloseShort())
		CloseShortPositions();

		if (ShouldOpenLong())
		OpenLongPosition();

		if (ShouldOpenShort())
		OpenShortPosition();
	}

	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;

		var order = BuyMarket(volume);
		RegisterOrder(order, OrderActions.OpenLong);
	}

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

		var order = SellMarket(volume);
		RegisterOrder(order, OrderActions.OpenShort);
	}

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

		var order = SellMarket(volume);
		RegisterOrder(order, OrderActions.CloseLong);
	}

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

		var order = BuyMarket(volume);
		RegisterOrder(order, OrderActions.CloseShort);
	}

	private void RegisterOrder(Order order, OrderActions action)
	{
		if (order == null)
		return;

		if (order.Id is long id)
			_orderActions[id] = action;
	}

	/// <inheritdoc />
	protected override void OnOwnTradeReceived(MyTrade trade)
	{
		base.OnOwnTradeReceived(trade);

		if (trade.Order.Id is not long tradeOrderId || !_orderActions.TryGetValue(tradeOrderId, out var action))
		return;

		var price = trade.Trade.Price;
		var volume = trade.Trade.Volume;

		switch (action)
		{
			case OrderActions.OpenLong:
			AddEntry(_longEntries, price, volume);
			break;

			case OrderActions.OpenShort:
			AddEntry(_shortEntries, price, volume);
			break;

			case OrderActions.CloseLong:
			RemoveVolume(_longEntries, volume);
			break;

			case OrderActions.CloseShort:
			RemoveVolume(_shortEntries, volume);
			break;
		}
	}

	/// <inheritdoc />
	protected override void OnOrderReceived(Order order)
	{
		base.OnOrderReceived(order);

		if (order.Id is long oid && order.State is OrderStates.Done or OrderStates.Failed)
		_orderActions.Remove(oid);
	}

	/// <inheritdoc />
	protected override void OnOrderRegisterFailed(OrderFail fail, bool calcRisk)
	{
		base.OnOrderRegisterFailed(fail, calcRisk);

		if (fail.Order.Id is long foid)
			_orderActions.Remove(foid);
	}

	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 void RemoveVolume(List<PositionEntry> entries, decimal volume)
	{
		var remaining = volume;

		for (var i = entries.Count - 1; i >= 0 && remaining > 0m; i--)
		{
			var entry = entries[i];

			if (entry.Volume <= remaining)
			{
				remaining -= entry.Volume;
				entries.RemoveAt(i);
			}
			else
			{
				entries[i] = entry.WithVolume(entry.Volume - remaining);
				remaining = 0m;
			}
		}
	}

	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; }

		public PositionEntry WithVolume(decimal volume)
		{
			return new PositionEntry(Price, volume);
		}
	}

	private enum OrderActions
	{
		OpenLong,
		CloseLong,
		OpenShort,
		CloseShort
	}
}