GitHub で見る

フランク・ウッド ミニマル戦略

このサンプルは、高レベルの戦略 API を使用して、古典的な Frank Ud MetaTrader エキスパート アドバイザーを StockSharp に移植します。元の MQL スクリプトは、最新のエントリーに対して価格が変動するたびにポジションを追加し続けるヘッジ マーチンゲール グリッドを実行します。最新の (したがって最大の) 注文が一定数の pips を獲得すると、利益がロックされ、その後、その側の すべての 取引が同時にクローズされます。

コアロジック

  1. 対称ヘッジ この戦略は、市場ポジションの 2 つの独立したはしご (長いはしごと短いはしご) を維持します。したがって、MetaTrader のヘッジ モードのように、ロングとショートを同時に保持することが可能です。
  2. Martingale の進行。 いずれかのサイドの最初のオーダーでは、InitialVolume (デフォルトは 0.1 ロット) が使用されます。同じ側​​の後続の各エントリは、現在開いている最大ボリュームを 2 倍にします。戦略が発注するロットは初回分も含めてすべて、銘柄が実際に受け付ける範囲に収められます。VolumeStep の整数倍へ切り捨て、MinVolume を下回っていれば MinVolume まで引き上げ、MaxVolume を超えていればそこで頭打ちにします。銘柄が提示しない制約は適用されません。
  3. エントリー間隔 新しいポジションは、価格が既存のラダーの最良エントリー価格を少なくとも ReEntryPips (デフォルトは 41 ピップス) 超えた場合にのみ追加されます。長いはしごは売値が lowest_buy - ReEntryPips を下回るのを待ち、短いはしごは買値が highest_sell + ReEntryPips を超えるまで待ちます。買値も売値も同じローソク足の終値から取られるため、この移植版では両方の比較が同一の価格に対して行われます。
  4. 利益の収穫。 各ラダーで、最大量の取引が「トリガー」注文として機能します。利益が TakeProfitPips (デフォルトは 65 ピップス) を超える場合、または価格がそのエントリーから TakeProfitPips + ExtraTakeProfitPips ピップス離れたバッファ付き目標に達すると、その側のすべてのポジションが 1 つの成行注文でフラット化され、ラダーは空になります。
  5. 証拠金の保護。 新規エントリーを送信する前に、戦略はポートフォリオの余剰証拠金 (現在評価額から報告された手数料を差し引いた額) が Balance × MinimumFreeMarginRatio (デフォルトは 0.5) を上回っているかを確認します。このガードは両方のラダーに、初回を含むすべてのエントリーへ適用されます。比率を 0 にすると無効になり、ポートフォリオが評価額を一切返さない場合も同様に、チェックは単に通過して戦略は元のエキスパートの固定量の動作に戻ります。

パラメーター

パラメータ 説明
TakeProfitPips 最新の最大注文で測定されたピップ利益のしきい値。それを超えると、その側のすべてのポジションがクローズされます。
ReEntryPips 新しいマーチンゲール注文が追加される前の、既存の最良のエントリーと現在の買値/買値の間の最小ピップ距離。
InitialVolume 各ラダーの最初の注文の基本ロット サイズ。後続の注文では、最大アクティブ量が 2 倍になります。
MinimumFreeMarginRatio 新規エントリーが許可される前に必要な残高に対する自由証拠金の比率。チェックを無効にするには、0 に設定します。デフォルトは 0.5。
ExtraTakeProfitPips バッファ付きの決済目標を計算する際に TakeProfitPips に加算される追加のピップス距離。デフォルトは 25。
CandleType 戦略が購読するローソク足のシリーズ。デフォルト: 1 分足。

実装メモ

  • 1 ピップは銘柄の生の値幅 (最小価格刻み) ではありません。戦略は最初に処理した確定済みローソク足で 1 ピップを提示価格の 1 万分の 1 と定め、銘柄の最小価格刻みを下限として (銘柄が実際に取引できる刻みより細かくならないように)、その値を実行の最後まで固定します。そのため、グリッドが自分の足元で動いてしまうことはありません。これは本エキスパートが前提としていた FX の慣習 (EURUSD が 1.10 なら 0.0001、USDJPY が 150 なら 0.01) を再現するものであり、5 桁で提示される銘柄でも各距離が意味を保ちます。生の刻み 0.01 をそのまま使うと、65 ピップスの利食い目標がほぼすべてのローソク足で達成されてしまいます。銘柄が最小価格刻みを提供しない場合は、この比率だけでピップが決まります。
  • この戦略はレベル 1 の相場ではなく、確定したローソク足によって駆動されます。CandleType のシリーズ (デフォルトは 1 分足) を購読し、まだ確定していないローソク足はすべて無視します。同梱の履歴には板情報がないため、確定したローソク足の終値が買値と売値の両方を兼ねます。C# 版と Python 版はまったく同じ方法で購読します。
  • ラダーへの記録は約定時ではなく、注文を送信した時点で行われます。建玉時にはローソク足の終値と要求した数量がリストに追加され、決済時にはラダー全体の数量に対する 1 つの成行注文が送信されてリストが空になります。注文の意図を保持する辞書も約定コールバックも使用しません。このエミュレータでは約定が注文登録の内部で同期的に返るため、注文をそのような辞書に書き込むより前に約定が届いてしまうからです。
  • ポジション ブックキーピングは、累積統計をクエリする代わりに、ラダーの各記録 (価格と出来高) を単純なリストに保存し、最大ロットとそのエントリー価格を見つけるために使用された MQL 配列の動作を保持します。
  • 元のエキスパートが各テイクプロフィット注文に設定した追加のピップ バッファーは ExtraTakeProfitPips パラメーター (デフォルトは 25 ピップス) として公開され、追加の終了条件として保持されます。

C# と 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; }
	}
}