GitHub で見る

Amstell グリッド戦略

Amstell グリッド戦略は、MetaTrader 5のエキスパートアドバイザーexp_Amstell.mq5のC#ポートです。対称的な買い/売りグリッドを作成し、個々のエントリーに仮想テイクプロフィットを適用します。変換はStockSharpの高レベルAPIガイドラインに従い、ティック処理をキャンドル処理に置き換えながら元のアイデアをそのまま維持します。

仕組み

  1. 初期化

    • 戦略は設定されたキャンドルタイプをサブスクライブし、ポジション保護を開始します。
    • 調整されたpipサイズが証券のPriceStepと小数精度から計算されます。5桁および3桁のシンボルはMT5の実装を反映して自動的に10倍の乗数を受け取ります。
  2. 最初の取引

    • 最後に記録された買い価格と売り価格の両方が空(初回起動)の場合、成行買い注文が即座に送信されます。これにより元のエキスパートアドバイザーとまったく同様にグリッドが起動します。
  3. グリッドの拡張

    • 現在の終値が最後に記録された買い価格より少なくともStepPips下にある場合、新しい買い注文が出されます。
    • 価格が最後に記録された売り価格より少なくともStepPips上にある場合、新しい売り注文が出されます。
    • 戦略は内部的に別々のロングとショートのスタックを追跡し、交互の注文がネッティング口座でも共存できるようにします。反対の注文は新しい建玉を追加する前にまず別のスタックを削減し、MT5バージョンのヘッジング動作を再現します。
  4. 仮想テイクプロフィット

    • 各オープンロングは独立して監視されます。価格がTakeProfitPips進むと、そのポジションのボリュームのみで成行売り注文が送信されます。
    • 各オープンショートは反対方向で同様に扱われます。テイクプロフィットは「仮想」で、ポジションはブローカー側のTP注文を使用せずにプログラム的に閉じられます。
    • 一方向が完全に閉じられ反対側がまだ存在する場合、対応する最後の取引価格がクリアされ、その方向の次の注文が元のコードと同様に即座に発動できるようになります。
  5. 状態追跡

    • OnOwnTradeReceivedハンドラーは実行された取引からロング/ショートスタックを再構築し、部分的なフィルと反転を適切に処理できるようにします。
    • 両サイドがフラットの場合、最後の買い/売り価格はキャッシュに残り、完全リセット後に再エントリーする前にグリッドが必要なステップを待てるようにします。

パラメーター

パラメーター デフォルト 説明
Volume 0.1 両方向のすべての成行注文に使用する注文サイズ。
TakeProfitPips 50 個々のポジションが閉じられる前に獲得する必要があるpips距離。
StepPips 15 同方向の連続したグリッド注文間のpipギャップ。
CandleType 1 Minute ティックベースのロジックを近似するために使用されるキャンドルデータソース。

すべてのpipベースの設定は証券の価格ステップと精度を尊重します。たとえばEURUSD(5桁)ではStepPips = 15は0.0015に対応します。

実践的な注意事項

  • 戦略はMT5コードに見られるティックレベルの比較をエミュレートするためにキャンドルの終値を使用します。高頻度の操作には時間枠を短くしてください。
  • デフォルトではストップロスは存在しません。どのようなグリッドアプローチでも同様に、暴走するトレンドは大きな建玉を積み上げる可能性があります。保守的なボリュームを使用し、セッションベースの監視を検討してください。
  • テイクプロフィットは仮想的に処理されるため、閉じた取引はブローカーで目に見えるTP注文を置くことなく即座に戦略のPnLに反映されます。
  • 実装は両サイドがフラットになった後、キャッシュされた最後の価格をそのままにします。これにより元の動作が保持され、グリッドは再起動する前に価格の変位を待ちます。

ファイル

  • CS/AmstellGridStrategy.cs – 詳細なインラインコメント付きのStockSharp戦略実装。
  • README.md, README_ru.md, README_zh.md – 英語、ロシア語、中国語の完全なドキュメント。

このポートはStockSharpエコシステム内で直接さらなるカスタマイズ(例:マネー管理、リスク制限)の準備ができています。

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>
/// Grid strategy that alternates buy and sell entries with a virtual take profit.
/// </summary>
public class AmstellGridStrategy : Strategy
{
	private sealed class PositionEntry
	{
		public PositionEntry(decimal price, decimal volume)
		{
			Price = price;
			Volume = volume;
		}

		public decimal Price { get; set; }

		public decimal Volume { get; set; }

		public bool IsClosing { get; set; }
	}

	private readonly StrategyParam<int> _takeProfitPips;
	private readonly StrategyParam<int> _stepPips;
	private readonly StrategyParam<DataType> _candleType;

	private readonly List<PositionEntry> _longEntries = new();
	private readonly List<PositionEntry> _shortEntries = new();

	private decimal? _lastBuyPrice;
	private decimal? _lastSellPrice;
	private bool _hasInitialOrder;
	private decimal _pipSize;


	/// <summary>
	/// Virtual take profit distance in pips.
	/// </summary>
	public int TakeProfitPips
	{
		get => _takeProfitPips.Value;
		set => _takeProfitPips.Value = value;
	}

	/// <summary>
	/// Distance between consecutive entries in pips.
	/// </summary>
	public int StepPips
	{
		get => _stepPips.Value;
		set => _stepPips.Value = value;
	}

	/// <summary>
	/// Candle type used to generate trade decisions.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	/// <summary>
	/// Initializes a new instance of the <see cref="AmstellGridStrategy"/> class.
	/// </summary>
	public AmstellGridStrategy()
	{

		_takeProfitPips = Param(nameof(TakeProfitPips), 50)
			.SetGreaterThanZero()
			.SetDisplay("Take Profit (pips)", "Virtual take profit distance", "Risk")
			
			.SetOptimize(10, 150, 10);

		_stepPips = Param(nameof(StepPips), 15)
			.SetGreaterThanZero()
			.SetDisplay("Step (pips)", "Distance between grid entries", "Grid")
			
			.SetOptimize(5, 60, 5);

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
			.SetDisplay("Candle Type", "Timeframe for signal candles", "General");
	}

	/// <inheritdoc />
	public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
	{
		return [(Security, CandleType)];
	}

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

		_longEntries.Clear();
		_shortEntries.Clear();
		_lastBuyPrice = null;
		_lastSellPrice = null;
		_hasInitialOrder = false;
		_pipSize = 0m;
	}

	/// <inheritdoc />
	protected override void OnStarted2(DateTime time)
	{
		base.OnStarted2(time);

		_pipSize = CalculatePipSize();

		var subscription = SubscribeCandles(CandleType);
		subscription
			.Bind(ProcessCandle)
			.Start();

		var area = CreateChartArea();
		if (area != null)
		{
			DrawCandles(area, subscription);
			DrawOwnTrades(area);
		}

	}

	private void ProcessCandle(ICandleMessage candle)
	{
		// Only react to completed candles to emulate stable tick processing.
		if (candle.State != CandleStates.Finished)
			return;


		var price = candle.ClosePrice;
		var stepDistance = GetStepDistance();
		var takeProfitDistance = GetTakeProfitDistance();

		// Bootstrap the grid exactly like the MQL version.
		if (!_hasInitialOrder && _lastBuyPrice is null && _lastSellPrice is null)
		{
			BuyMarket(Volume);
			_hasInitialOrder = true;
			return;
		}

		// Check whether the grid should add a new long layer.
		if (CanOpenBuy(price, stepDistance))
		{
			BuyMarket(Volume);
			return;
		}

		// Mirror logic for the short side of the grid.
		if (CanOpenSell(price, stepDistance))
		{
			SellMarket(Volume);
			return;
		}

		// No new entries were placed, so check for virtual take-profit exits.
		if (TryClosePositions(price, takeProfitDistance))
			return;
	}

	private bool CanOpenBuy(decimal price, decimal stepDistance)
	{
		if (Volume <= 0)
			return false;

		return !_lastBuyPrice.HasValue || _lastBuyPrice.Value - price >= stepDistance;
	}

	private bool CanOpenSell(decimal price, decimal stepDistance)
	{
		if (Volume <= 0)
			return false;

		return !_lastSellPrice.HasValue || price - _lastSellPrice.Value >= stepDistance;
	}

	private bool TryClosePositions(decimal price, decimal takeProfitDistance)
	{
		if (takeProfitDistance <= 0)
			return false;

		// Evaluate longs first because the original EA does the same.
		foreach (var entry in _longEntries)
		{
			if (entry.IsClosing)
				continue;

			if (price - entry.Price >= takeProfitDistance)
			{
				// Prevent duplicate closing requests until the trade is processed.
				entry.IsClosing = true;
				SellMarket(entry.Volume);
				return true;
			}
		}

		// Short entries use the symmetrical distance check.
		foreach (var entry in _shortEntries)
		{
			if (entry.IsClosing)
				continue;

			if (entry.Price - price >= takeProfitDistance)
			{
				entry.IsClosing = true;
				BuyMarket(entry.Volume);
				return true;
			}
		}

		return false;
	}

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

		if (trade?.Order == null || trade.Order.Security != Security)
			return;

		var volume = trade.Trade.Volume;

		// Feed the executed trade into the synthetic short stack first.
		if (trade.Order.Side == Sides.Buy)
		{
			var remainder = ReduceEntries(_shortEntries, volume);

			if (remainder > 0)
			{
				// Remaining volume becomes a new long layer.
				_longEntries.Add(new PositionEntry(trade.Trade.Price, remainder));
				_lastBuyPrice = trade.Trade.Price;
			}
		}
		else if (trade.Order.Side == Sides.Sell)
		{
			var remainder = ReduceEntries(_longEntries, volume);

			if (remainder > 0)
			{
				// Remaining volume becomes a new short layer.
				_shortEntries.Add(new PositionEntry(trade.Trade.Price, remainder));
				_lastSellPrice = trade.Trade.Price;
			}
		}

		// Recalculate helper state after rebuilding the stacks.
		UpdateLastPrices();
	}

	private decimal ReduceEntries(List<PositionEntry> entries, decimal volume)
	{
		var remaining = volume;

		// Consume volume using a FIFO approach just like MT5 positions.
		while (remaining > 0 && entries.Count > 0)
		{
			var entry = entries[0];
			var used = Math.Min(entry.Volume, remaining);
			entry.Volume -= used;
			remaining -= used;

			if (entry.Volume <= 0)
			{
				// Entry fully closed, remove it from the stack.
				entries.RemoveAt(0);
			}
			else
			{
				// Partial reduction keeps the entry alive; clear closing flag.
				entry.IsClosing = false;
			}
		}

		return remaining;
	}

	private void UpdateLastPrices()
	{
		// If only shorts remain, unlock the buy grid for immediate reuse.
		if (_longEntries.Count == 0 && _shortEntries.Count > 0)
		{
			_lastBuyPrice = null;
		}

		// If only longs remain, clear the last sell price to mimic MT5 logic.
		if (_shortEntries.Count == 0 && _longEntries.Count > 0)
		{
			_lastSellPrice = null;
		}

		// Any surviving entries should be marked as active again.
		for (var i = 0; i < _longEntries.Count; i++)
		{
			_longEntries[i].IsClosing = false;
		}

		for (var i = 0; i < _shortEntries.Count; i++)
		{
			_shortEntries[i].IsClosing = false;
		}
	}

	private decimal GetStepDistance()
	{
		var pip = _pipSize;
		if (pip <= 0)
		{
			// Fallback to the raw price step if the pip size has not been initialized yet.
			pip = Security?.PriceStep ?? 1m;
		}

		return StepPips * pip;
	}

	private decimal GetTakeProfitDistance()
	{
		var pip = _pipSize;
		if (pip <= 0)
		{
			// Same fallback logic as the step distance.
			pip = Security?.PriceStep ?? 1m;
		}

		return TakeProfitPips * pip;
	}

	private decimal CalculatePipSize()
	{
		var step = Security?.PriceStep ?? 0m;
		if (step <= 0)
			step = 1m;

		return step;
	}
}