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. Jedes Lot, das die Strategie sendet – auch das allererste –, wird anschließend auf das begrenzt, was das Instrument tatsächlich annimmt: auf ein ganzzahliges Vielfaches von VolumeStep abgerundet, auf MinVolume angehoben, falls es darunter liegt, und bei MaxVolume gedeckelt. Einschränkungen, die das Instrument nicht meldet, werden übersprungen.
  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. Die lange Leiter wartet darauf, dass der Briefkurs unter lowest_buy - ReEntryPips fällt, während die kurze Leiter darauf wartet, dass der Geldkurs über highest_sell + ReEntryPips steigt. Beide Seiten der Notierung stammen aus dem Schlusskurs derselben Kerze, sodass in dieser Portierung beide Vergleiche gegen denselben Preis erfolgen.
  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 gepufferte Ziel erreicht, das TakeProfitPips + ExtraTakeProfitPips Pips von diesem Einstieg entfernt liegt, wird jede Position auf dieser Seite mit einer einzigen Marktorder abgeflacht und die Leiter geleert.
  5. Margin-Schutz. Bevor ein neuer Eintrag eingereicht wird, überprüft die Strategie, ob die freie Marge des Portfolios – sein aktueller Wert abzüglich der gemeldeten Kommission – über Balance × MinimumFreeMarginRatio (Standard 0,5) bleibt. Die Absicherung gilt für beide Leitern und für jeden Eintrag darauf, den allerersten eingeschlossen. Ein Verhältnis von null schaltet sie ab; dasselbe geschieht, wenn das Portfolio überhaupt keinen Wert meldet – in beiden Fällen ist die Prüfung schlicht bestanden und die Strategie fällt auf das Festvolumenverhalten des ursprünglichen Experten zurück.

Parameter

Parameter Beschreibung
TakeProfitPips Pip-Gewinnschwelle, gemessen an der letzten, größten Bestellung. Bei Überschreitung werden alle Positionen auf dieser Seite geschlossen.
ReEntryPips Mindest-Pip-Abstand zwischen dem besten vorhandenen Eintrag und dem aktuellen Geld-/Briefkurs, bevor eine neue Martingal-Order hinzugefügt wird.
InitialVolume Basis-Lotgröße für die erste Order jeder Leiter. 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. Standard 0,5.
ExtraTakeProfitPips Zusätzlicher Pip-Abstand, der bei der Berechnung des gepufferten Ausstiegsziels zu TakeProfitPips addiert wird. Standard 25.
CandleType Kerzenserie, die die Strategie abonniert. Standard: Zeitrahmen von 1 Minute.

Hinweise zur Implementierung

  • Ein Pip ist nicht die rohe Preisschrittweite. Bei der ersten verarbeiteten abgeschlossenen Kerze setzt die Strategie einen Pip auf ein Zehntausendstel des notierten Preises, begrenzt ihn nach unten durch die Preisschrittweite des Instruments (damit er nie feiner ist, als das Instrument tatsächlich handelt) und behält diesen Wert für den restlichen Lauf bei, damit sich das Gitter nicht unter sich selbst verschiebt. Damit wird die Forex-Konvention nachgebildet, für die der Experte geschrieben wurde (0,0001 bei EURUSD zu 1,10; 0,01 bei USDJPY zu 150), und die Abstände bleiben auch bei einem fünfstellig notierten Instrument sinnvoll, bei dem die rohe Schrittweite von 0,01 ein Ziel von 65 Pips bei nahezu jeder Kerze erreichen würde. Meldet das Instrument keine Preisschrittweite, wird der Pip allein durch diesen Bruchteil bestimmt.
  • Die Strategie wird von abgeschlossenen Kerzen angetrieben, nicht von Notierungen der Stufe 1. Sie abonniert die CandleType-Serie (standardmäßig ein Zeitrahmen von 1 Minute) und ignoriert jede noch nicht abgeschlossene Kerze. Die mitgelieferte Historie enthält kein Orderbuch, daher dient der Schlusskurs der abgeschlossenen Kerze zugleich als Geld- und als Briefkurs. Die C#- und die Python-Implementierung abonnieren auf genau dieselbe Weise.
  • Ein Leitereintrag wird in dem Moment festgehalten, in dem die Order gesendet wird, und nicht bei ihrer Ausführung: Beim Öffnen werden der Schlusskurs der Kerze und das angeforderte Volumen an die Liste angehängt, beim Schließen wird eine einzige Marktorder über das gesamte Volumen der Leiter gesendet und die Liste geleert. Es wird kein Wörterbuch mit Orderabsichten geführt und kein Ausführungs-Callback verwendet – in diesem Emulator wird die Ausführung synchron innerhalb der Orderregistrierung geliefert, noch bevor die Order überhaupt in ein solches Wörterbuch geschrieben werden könnte.
  • Die Positionsbuchhaltung speichert jeden Leitereintrag (Preis und Volumen) in einfachen 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 Pip-Puffer, den der ursprüngliche Experte bei jeder Take-Profit-Order platziert hat, ist als Parameter ExtraTakeProfitPips (standardmäßig 25 Pips) verfügbar und wird als zusätzliche Ausstiegsbedingung beibehalten.

Implementierungen sind in C# und Python verfügbar.

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