Auf GitHub ansehen

Frank Ud Minimalstrategie

Dieses Beispiel portiert den klassischen Frank Ud MetaTrader-Expertenberater nach StockSharp unter Verwendung der High-Level-Strategie API. Das ursprüngliche MQL-Skript führt ein abgesichertes Martingal-Gitter aus, das jedes Mal Positionen hinzufügt, wenn sich der Preis gegenüber dem letzten Eintrag bewegt. Gewinne werden gesperrt, sobald die letzte (und damit größte) Order eine festgelegte Anzahl an Pips erreicht. Danach werden alle Trades auf dieser Seite gleichzeitig geschlossen.

Kernlogik

  1. Symmetrische Absicherung. Die Strategie unterhält zwei unabhängige Leitern von Marktpositionen: eine lange Leiter und eine kurze Leiter. Daher ist es möglich, gleichzeitig Long- und Short-Positionen zu halten, wie im Absicherungsmodus von MetaTrader.
  2. Martingale-Progression. Die erste Bestellung auf einer Seite verwendet InitialVolume (Standard 0,1 Lots). Jeder weitere Eintrag auf derselben Seite verdoppelt das größte derzeit offene Volumen. Lautstärkeanpassungen berücksichtigen die MinVolume-, MaxVolume- und VolumeStep-Einschränkungen des Instruments.
  3. Einstiegsabstand. Eine neue Position wird nur hinzugefügt, wenn sich der Preis um mindestens ReEntryPips (Standard 41 Pips) über den besten Einstiegspreis der bestehenden Leiter hinaus bewegt hat. The long ladder waits for ask prices to drop below lowest_buy - ReEntryPips, while the short ladder waits for bid prices to rise above highest_sell + ReEntryPips.
  4. Gewinnernte. Für jede Leiter fungiert der Trade mit dem größten Volumen als „Trigger“-Order. Wenn sein Gewinn TakeProfitPips (Standard 65 Pips) übersteigt oder wenn der Preis das implizite Take-Profit-Level (TakeProfitPips + 25) berührt, das von der MQL-Version verwendet wird, wird jede Position auf dieser Seite mit einer einzigen Marktorder abgeflacht.
  5. Margin-Schutz. Bevor ein neuer Eintrag eingereicht wird, überprüft die Strategie, ob die vom Portfolio gemeldete freie Marge (CurrentValue - BlockedValue) über Balance × MinimumFreeMarginRatio (Standard 0,5) bleibt. Wenn der Broker keine Portfoliostatistiken meldet, greift die Prüfung auf das Festvolumenverhalten des ursprünglichen Experten zurück.

Parameter

Parameter Beschreibung
TakeProfitPips Pip-Gewinnschwelle, gemessen an der letzten, größten Bestellung. Once exceeded, all positions on that side are closed.
ReEntryPips Mindest-Pip-Abstand zwischen dem besten vorhandenen Eintrag und dem aktuellen Geld-/Briefkurs, bevor eine neue Martingal-Order hinzugefügt wird.
InitialVolume Base lot size for the first order of each ladder. Folgeaufträge verdoppeln das größte aktive Volumen.
MinimumFreeMarginRatio Erforderliches Verhältnis von freier Margin zu Guthaben, bevor neue Einträge zugelassen werden. Auf 0 setzen, um die Prüfung zu deaktivieren.

Hinweise zur Implementierung

  • Die Strategie basiert ausschließlich auf Notierungen der Stufe 1: Gebotsaktualisierungen steuern die Short-Ladder-Logik und Briefaktualisierungen steuern die Long-Ladder-Logik.
  • Bestellabsichten werden in einem internen Wörterbuch verfolgt, sodass OnNewMyTrade weiß, ob eine Ausführung eine Leiter geöffnet oder geschlossen hat. Dies ahmt die explizite Ticketbuchhaltung in der Quelle MQL nach.
  • Die Positionsbuchhaltung speichert jede Ausführung (Preis und Volumen) in Listen, anstatt kumulative Statistiken abzufragen, und behält dabei das Verhalten der MQL-Arrays bei, die zum Auffinden des größten Loses und seines Einstiegspreises verwendet wurden.
  • Der zusätzliche 25-Pip-Puffer, den der ursprüngliche Experte bei jeder Take-Profit-Order platziert hat, wird als zusätzliche Ausstiegsbedingung beibehalten.

Hinweis: Der Python-Port wird wie gewünscht vorerst absichtlich weggelassen. Der Ordner enthält nur die C#-Implementierung und die mehrsprachige Dokumentation.

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