Ver no GitHub

Estratégia Mínima de Frank Ud

Este exemplo transporta o consultor especialista clássico Frank Ud MetaTrader para StockSharp usando a estratégia de alto nível API. O script MQL original executa uma grade martingale protegida que continua adicionando posições sempre que o preço se move em relação à entrada mais recente. Os lucros são bloqueados quando a ordem mais recente (e, portanto, maior) ganha um número fixo de pips, após o qual todas as negociações desse lado são fechadas simultaneamente.

Lógica central

  1. Hedge simétrico. A estratégia mantém duas escadas independentes de posições de mercado: uma escada longa e uma escada curta. Portanto, é possível manter posições longas e curtas ao mesmo tempo, como no modo de hedge de MetaTrader.
  2. Martingale progressão. A primeira ordem de qualquer lado usa InitialVolume (padrão 0,1 lote). Cada entrada subsequente no mesmo lado duplica o maior volume atualmente aberto. Os ajustes de volume respeitam as restrições MinVolume, MaxVolume e VolumeStep do instrumento.
  3. Espaçamento de entrada. Uma nova posição é adicionada somente quando o preço se moveu pelo menos ReEntryPips (padrão 41 pips) além do melhor preço de entrada da escada existente. A escada longa espera que os preços de venda caiam abaixo de lowest_buy - ReEntryPips, enquanto a escada curta espera que os preços de compra subam acima de highest_sell + ReEntryPips.
  4. Coleta de lucros. Para cada escada, a negociação com o maior volume atua como a ordem de "gatilho". Quando seu lucro excede TakeProfitPips (padrão 65 pips), ou quando o preço atinge o nível de lucro implícito (TakeProfitPips + 25) usado pela versão MQL, cada posição desse lado é achatada com uma única ordem de mercado.
  5. Proteção de margem. Antes de enviar qualquer nova entrada a estratégia verifica se a margem livre informada pela carteira (CurrentValue - BlockedValue) permanece acima de Balance × MinimumFreeMarginRatio (padrão 0,5). Se a corretora não reportar estatísticas da carteira, a verificação volta ao comportamento de volume fixo do especialista original.

Parâmetros

Parâmetro Descrição
TakeProfitPips Limite de lucro do pip medido no maior e mais recente pedido. Uma vez ultrapassado, todas as posições desse lado serão fechadas.
ReEntryPips Distância mínima de pip entre a melhor entrada existente e a oferta/venda atual antes de uma nova ordem de martingale ser adicionada.
InitialVolume Tamanho base do lote para a primeira ordem de cada escada. Os pedidos subsequentes dobram o maior volume ativo.
MinimumFreeMarginRatio Proporção necessária entre margem livre e saldo antes que novas entradas sejam permitidas. Defina como 0 para desativar a verificação.

Notas de implementação

  • A estratégia depende exclusivamente de cotações de nível 1: as atualizações de lances impulsionam a lógica de escada curta e as atualizações de pedido impulsionam a lógica de escada longa.
  • As intenções do pedido são rastreadas em um dicionário interno para que OnNewMyTrade saiba se um preenchimento abriu ou fechou uma escada. Isso imita a escrituração explícita de tickets na fonte MQL.
  • A contabilidade de posição armazena cada preenchimento (preço e volume) em listas em vez de consultar estatísticas cumulativas, preservando o comportamento das matrizes MQL que foram usadas para localizar o maior lote e seu preço de entrada.
  • O buffer extra de 25 pips que o especialista original colocou em cada ordem de realização de lucro é retido como uma condição de saída adicional.

Nota: A porta Python foi omitida intencionalmente por enquanto, conforme solicitado. A pasta contém apenas a implementação do C# e a documentação multilíngue.

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