GitHub で見る

ギャラクティック・エクスプロージョン・ストラテジー

概要

ギャラクティック・エクスプロージョン・ストラテジーは、元のMetaTrader 5グリッドエキスパートをStockSharpで再構築します。完成したローソク足で動作し、長期移動平均を使用して方向バイアスを定義し、拡大するグリッドの注文を展開します。価格が移動平均の片側に留まる間、システムはトレードを蓄積し、事前に定義された利益目標が達成されるとバスケット全体を閉じます。

市場ロジック

  1. 方向フィルター – ストラテジーは最新のローソク足の終値を移動平均と比較します。価格が平均を下回って閉じるとバイアスが強気になり、価格が平均を上回って閉じるとバイアスが弱気になります。
  2. プログレッシブグリッド – 最初の8回のエントリーはバイアスが許す限りいつでも取られます。8番目のポジションの後、現在の価格と最後および最初のエントリーの両方との距離が、追加のトレードが許可されるかどうかを制御します。
  3. 間隔コントロール – 距離は価格ステップで測定されます。価格が最後のエントリーから十分遠くに移動した場合、ストラテジーはバスケットに追加します。最初のエントリーまでの距離に応じて、すぐに取引するか、3本のローソク足をスキップするか、または再度追加する前に6本のローソク足をスキップします。
  4. 利益実現 – 実現PnLとバスケットのオープン利益の合計が最小利益目標と比較されます。閾値に達すると、すべてのオープンポジションが単一の成行注文で閉じられます。

トレード管理

  • エントリーボリューム – 各トレードは設定された注文ボリュームで実行されます。ポジションを保持しながらシグナルが転換する場合、ストラテジーは旧サイドを閉じ、必要な追加ボリュームで新しいサイドを開く単一の注文を送信します。
  • ポジション追跡 – ストラテジーは、ロングおよびショートバスケットの平均価格と最初/最後のエントリー価格を独立して維持します。これにより、元のエキスパートの距離ベースのスケーリングルールを再現できます。
  • セッションフィルター – 取引は設定された開始時間と終了時間の間のみアクティブです。ロジックはローソク足の始値時間を使用し、この窓の外のシグナルは無視します。
  • 安全チェック – 取引窓が誤って設定されている場合(例えば、開始時間が終了時間より前でない場合)、ストラテジーは取引をスキップし警告を記録します。

パラメーター

パラメーター 説明
Order Volume 各新規エントリーに使用するボリューム。この値は現在開いているグリッドステップ数を推定するためにも使用されます。
Start Hour 取引所時間での取引セッション開始時間。この時間より前のシグナルは無視されます。
End Hour 取引セッション終了時間(排他的)。この時間より後のシグナルは無視されます。
Minimal Profit すべてのオープンポジションを閉じることをトリガーする、実現利益と未実現利益の合計。
Indent After 8th 8回のトレード後に別のポジションを開く前に、直近エントリーからの最小距離(価格ステップ単位)。
Skip 3 Min 「3本のローソク足をスキップ」ルールを有効化するための下限(価格ステップ単位)。
Skip 3 Max 「3本のローソク足をスキップ」ルールをアクティブに保つ上限(価格ステップ単位)。
Skip 6 Max 「6本のローソク足をスキップ」ルールをアクティブに保つ上限(価格ステップ単位)。
MA Length 方向バイアスを定義する単純移動平均の長さ。
Candle Type ストラテジーがサブスクライブするローソク足シリーズ。移動平均とグリッドロジックはこのデータストリームで実行されます。

実装メモ

  • ストラテジーはSimpleMovingAverageインジケーターと共にSubscribeCandlesを使用し、完成したローソク足のみを処理します。
  • ポジション統計はOnNewMyTradeを通じて維持され、最初と最後のエントリー価格、およびオープンバスケットの平均価格を正確に追跡できます。
  • 距離の閾値はセキュリティのPriceStepでスケーリングされ、MT5エキスパートの元のpipベースの設定を再現します。
  • 実装はカスタムコレクションを避け、プロジェクトガイドラインに準拠するためにスカラー状態変数に焦点を当てています。
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>
/// Strategy that recreates the Galactic Explosion grid behavior using a moving average bias and distance based scaling.
/// </summary>
public class GalacticExplosionStrategy : Strategy
{
	private readonly StrategyParam<decimal> _orderVolume;
	private readonly StrategyParam<int> _startHour;
	private readonly StrategyParam<int> _endHour;
	private readonly StrategyParam<decimal> _minimalProfit;
	private readonly StrategyParam<decimal> _indentAfterEighth;
	private readonly StrategyParam<decimal> _skipThreeCandlesMin;
	private readonly StrategyParam<decimal> _skipThreeCandlesMax;
	private readonly StrategyParam<decimal> _skipSixCandlesMax;
	private readonly StrategyParam<int> _maLength;
	private readonly StrategyParam<DataType> _candleType;

	private SimpleMovingAverage _movingAverage;

	private int _longEntries;
	private int _shortEntries;
	private decimal _firstLongPrice;
	private decimal _lastLongPrice;
	private decimal _firstShortPrice;
	private decimal _lastShortPrice;
	private int _missedBarsLong;
	private int _missedBarsShort;
	private decimal _longPositionVolume;
	private decimal _shortPositionVolume;
	private decimal? _longAveragePrice;
	private decimal? _shortAveragePrice;
	private bool _invalidHoursLogged;

	/// <summary>
	/// Order volume used for every new entry.
	/// </summary>
	public decimal OrderVolume
	{
		get => _orderVolume.Value;
		set => _orderVolume.Value = value;
	}

	/// <summary>
	/// Trading window start hour in 24h format.
	/// </summary>
	public int StartHour
	{
		get => _startHour.Value;
		set => _startHour.Value = value;
	}

	/// <summary>
	/// Trading window end hour in 24h format.
	/// </summary>
	public int EndHour
	{
		get => _endHour.Value;
		set => _endHour.Value = value;
	}

	/// <summary>
	/// Profit threshold combining realized and open PnL at which all positions are closed.
	/// </summary>
	public decimal MinimalProfit
	{
		get => _minimalProfit.Value;
		set => _minimalProfit.Value = value;
	}

	/// <summary>
	/// Minimum distance from the most recent entry (expressed in price steps) required after the eighth trade.
	/// </summary>
	public decimal IndentAfterEighth
	{
		get => _indentAfterEighth.Value;
		set => _indentAfterEighth.Value = value;
	}

	/// <summary>
	/// Minimum distance from the first entry to trigger the skip three candles logic (in price steps).
	/// </summary>
	public decimal SkipThreeCandlesMin
	{
		get => _skipThreeCandlesMin.Value;
		set => _skipThreeCandlesMin.Value = value;
	}

	/// <summary>
	/// Maximum distance from the first entry that still keeps the skip three candles logic active (in price steps).
	/// </summary>
	public decimal SkipThreeCandlesMax
	{
		get => _skipThreeCandlesMax.Value;
		set => _skipThreeCandlesMax.Value = value;
	}

	/// <summary>
	/// Maximum distance from the first entry that keeps the skip six candles logic active (in price steps).
	/// </summary>
	public decimal SkipSixCandlesMax
	{
		get => _skipSixCandlesMax.Value;
		set => _skipSixCandlesMax.Value = value;
	}

	/// <summary>
	/// Length of the moving average filter.
	/// </summary>
	public int MaLength
	{
		get => _maLength.Value;
		set => _maLength.Value = value;
	}

	/// <summary>
	/// Candle type used for calculations.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	/// <summary>
	/// Initializes a new instance of <see cref="GalacticExplosionStrategy"/>.
	/// </summary>
	public GalacticExplosionStrategy()
	{
		_orderVolume = Param(nameof(OrderVolume), 0.1m)
		.SetGreaterThanZero()
		.SetDisplay("Order Volume", "Volume for each new entry", "Trading");

		_startHour = Param(nameof(StartHour), 8)
		.SetDisplay("Start Hour", "Trading session start hour", "Trading");

		_endHour = Param(nameof(EndHour), 17)
		.SetDisplay("End Hour", "Trading session end hour", "Trading");

		_minimalProfit = Param(nameof(MinimalProfit), 1m)
		.SetDisplay("Minimal Profit", "Target profit to close the grid", "Risk");

		_indentAfterEighth = Param(nameof(IndentAfterEighth), 500m)
		.SetDisplay("Indent After 8th", "Distance from last entry after eight trades (price steps)", "Grid");

		_skipThreeCandlesMin = Param(nameof(SkipThreeCandlesMin), 500m)
		.SetDisplay("Skip 3 Min", "Lower distance to start skipping three candles", "Grid");

		_skipThreeCandlesMax = Param(nameof(SkipThreeCandlesMax), 999m)
		.SetDisplay("Skip 3 Max", "Upper distance to keep skipping three candles", "Grid");

		_skipSixCandlesMax = Param(nameof(SkipSixCandlesMax), 2000m)
		.SetDisplay("Skip 6 Max", "Upper distance to keep skipping six candles", "Grid");

		_maLength = Param(nameof(MaLength), 10)
		.SetGreaterThanZero()
		.SetDisplay("MA Length", "Length of the moving average", "Filter");

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
		.SetDisplay("Candle Type", "Primary candle series", "General");
	}

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

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

		_longEntries = 0;
		_shortEntries = 0;
		_firstLongPrice = 0m;
		_lastLongPrice = 0m;
		_firstShortPrice = 0m;
		_lastShortPrice = 0m;
		_missedBarsLong = 0;
		_missedBarsShort = 0;
		_longPositionVolume = 0m;
		_shortPositionVolume = 0m;
		_longAveragePrice = null;
		_shortAveragePrice = null;
		_invalidHoursLogged = false;
	}

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

		_movingAverage = new SimpleMovingAverage
		{
			Length = MaLength
		};

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

		// no protection needed
	}

	private void ProcessCandle(ICandleMessage candle, decimal maValue)
	{
		if (candle.State != CandleStates.Finished)
		return;

		if (!_movingAverage.IsFormed)
		return;

		var totalProfit = PnL + GetOpenProfit(candle.ClosePrice);
		if (MinimalProfit > 0m && totalProfit >= MinimalProfit && Position != 0m)
		{
			if (Position > 0)
				SellMarket(Position);
			else if (Position < 0)
				BuyMarket(-Position);
			return;
		}

		if (!IsFormedAndOnlineAndAllowTrading())
		return;

		if (!IsWithinTradingWindow(candle.OpenTime))
		return;

		var close = candle.ClosePrice;
		var needBuy = close < maValue;
		var needSell = close > maValue;

		var entries = GetCurrentEntries();

		if (entries <= 8)
		{
			if (needBuy)
			{
				EnterLong();
			}
			else if (needSell)
			{
				EnterShort();
			}

			return;
		}

		var priceStep = GetPriceStep();
		var indentAfterEighth = priceStep * IndentAfterEighth;
		var skipThreeMin = priceStep * SkipThreeCandlesMin;
		var skipThreeMax = priceStep * SkipThreeCandlesMax;
		var skipSixMax = priceStep * SkipSixCandlesMax;

		if (Position > 0m)
		{
			ProcessLongGrid(close, needBuy, indentAfterEighth, skipThreeMin, skipThreeMax, skipSixMax);
		}
		else if (Position < 0m)
		{
			ProcessShortGrid(close, needSell, indentAfterEighth, skipThreeMin, skipThreeMax, skipSixMax);
		}
	}

	private void ProcessLongGrid(decimal price, bool needBuy, decimal indentAfterEighth, decimal skipThreeMin, decimal skipThreeMax, decimal skipSixMax)
	{
		if (_lastLongPrice <= 0m || _firstLongPrice <= 0m)
		return;

		var lastDistance = Math.Abs(price - _lastLongPrice);
		if (lastDistance <= indentAfterEighth)
		return;

		var firstDistance = Math.Abs(price - _firstLongPrice);

		if (firstDistance < skipThreeMin)
		{
			_missedBarsLong = 0;

			if (needBuy)
			EnterLong();
		}
		else if (firstDistance <= skipThreeMax)
		{
			_missedBarsLong++;

			if (_missedBarsLong > 3)
			{
				if (needBuy)
				EnterLong();

				_missedBarsLong = 0;
			}
		}
		else if (firstDistance <= skipSixMax)
		{
			_missedBarsLong++;

			if (_missedBarsLong > 6)
			{
				if (needBuy)
				EnterLong();

				_missedBarsLong = 0;
			}
		}
	}

	private void ProcessShortGrid(decimal price, bool needSell, decimal indentAfterEighth, decimal skipThreeMin, decimal skipThreeMax, decimal skipSixMax)
	{
		if (_lastShortPrice <= 0m || _firstShortPrice <= 0m)
		return;

		var lastDistance = Math.Abs(price - _lastShortPrice);
		if (lastDistance <= indentAfterEighth)
		return;

		var firstDistance = Math.Abs(price - _firstShortPrice);

		if (firstDistance < skipThreeMin)
		{
			_missedBarsShort = 0;

			if (needSell)
			EnterShort();
		}
		else if (firstDistance <= skipThreeMax)
		{
			_missedBarsShort++;

			if (_missedBarsShort > 3)
			{
				if (needSell)
				EnterShort();

				_missedBarsShort = 0;
			}
		}
		else if (firstDistance <= skipSixMax)
		{
			_missedBarsShort++;

			if (_missedBarsShort > 6)
			{
				if (needSell)
				EnterShort();

				_missedBarsShort = 0;
			}
		}
	}

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

		if (trade.Trade == null)
		return;

		var price = trade.Trade.Price;
		var volume = trade.Trade.Volume;

		if (volume <= 0m)
		return;

		if (trade.Order.Side == Sides.Buy)
		{
			HandleBuyTrade(volume, price);
		}
		else if (trade.Order.Side == Sides.Sell)
		{
			HandleSellTrade(volume, price);
		}

		if (Position == 0m)
		{
			ResetLongState();
			ResetShortState();
		}
	}

	private void HandleBuyTrade(decimal volume, decimal price)
	{
		if (_shortPositionVolume > 0m)
		{
			var closingVolume = Math.Min(volume, _shortPositionVolume);
			_shortPositionVolume -= closingVolume;
			ReduceShortEntries(closingVolume);

			if (_shortPositionVolume <= 0m)
			{
				ResetShortState();
			}

			var remaining = volume - closingVolume;
			if (remaining > 0m)
			{
				AddLong(remaining, price);
			}
		}
		else
		{
			AddLong(volume, price);
		}
	}

	private void HandleSellTrade(decimal volume, decimal price)
	{
		if (_longPositionVolume > 0m)
		{
			var closingVolume = Math.Min(volume, _longPositionVolume);
			_longPositionVolume -= closingVolume;
			ReduceLongEntries(closingVolume);

			if (_longPositionVolume <= 0m)
			{
				ResetLongState();
			}

			var remaining = volume - closingVolume;
			if (remaining > 0m)
			{
				AddShort(remaining, price);
			}
		}
		else
		{
			AddShort(volume, price);
		}
	}

	private void AddLong(decimal volume, decimal price)
	{
		if (volume <= 0m)
		return;

		var previousVolume = _longPositionVolume;
		var newVolume = previousVolume + volume;

		if (newVolume <= 0m)
		return;

		if (previousVolume <= 0m)
		{
			_firstLongPrice = price;
			_missedBarsLong = 0;
		}

		_longEntries += GetEntryCountFromVolume(volume);
		_lastLongPrice = price;
		_longPositionVolume = newVolume;

		if (_longAveragePrice is decimal avg && previousVolume > 0m)
		{
			_longAveragePrice = ((avg * previousVolume) + (price * volume)) / newVolume;
		}
		else
		{
			_longAveragePrice = price;
		}
	}

	private void AddShort(decimal volume, decimal price)
	{
		if (volume <= 0m)
		return;

		var previousVolume = _shortPositionVolume;
		var newVolume = previousVolume + volume;

		if (newVolume <= 0m)
		return;

		if (previousVolume <= 0m)
		{
			_firstShortPrice = price;
			_missedBarsShort = 0;
		}

		_shortEntries += GetEntryCountFromVolume(volume);
		_lastShortPrice = price;
		_shortPositionVolume = newVolume;

		if (_shortAveragePrice is decimal avg && previousVolume > 0m)
		{
			_shortAveragePrice = ((avg * previousVolume) + (price * volume)) / newVolume;
		}
		else
		{
			_shortAveragePrice = price;
		}
	}

	private void ReduceLongEntries(decimal volume)
	{
		if (volume <= 0m || _longEntries <= 0)
		return;

		_longEntries = Math.Max(0, _longEntries - GetEntryCountFromVolume(volume));
	}

	private void ReduceShortEntries(decimal volume)
	{
		if (volume <= 0m || _shortEntries <= 0)
		return;

		_shortEntries = Math.Max(0, _shortEntries - GetEntryCountFromVolume(volume));
	}

	private void ResetLongState()
	{
		_longEntries = 0;
		_firstLongPrice = 0m;
		_lastLongPrice = 0m;
		_missedBarsLong = 0;
		_longPositionVolume = 0m;
		_longAveragePrice = null;
	}

	private void ResetShortState()
	{
		_shortEntries = 0;
		_firstShortPrice = 0m;
		_lastShortPrice = 0m;
		_missedBarsShort = 0;
		_shortPositionVolume = 0m;
		_shortAveragePrice = null;
	}

	private void EnterLong()
	{
		if (OrderVolume <= 0m)
		return;

		var volume = OrderVolume;

		if (Position < 0m)
		volume += Math.Abs(Position);

		if (volume > 0m)
		BuyMarket(volume);
	}

	private void EnterShort()
	{
		if (OrderVolume <= 0m)
		return;

		var volume = OrderVolume;

		if (Position > 0m)
		volume += Math.Abs(Position);

		if (volume > 0m)
		SellMarket(volume);
	}

	private decimal GetOpenProfit(decimal price)
	{
		if (Position > 0m && _longAveragePrice is decimal longAvg)
		return Position * (price - longAvg);

		if (Position < 0m && _shortAveragePrice is decimal shortAvg)
		return Math.Abs(Position) * (shortAvg - price);

		return 0m;
	}

	private int GetCurrentEntries()
	{
		if (Position > 0m)
		return _longEntries;

		if (Position < 0m)
		return _shortEntries;

		return 0;
	}

	private int GetEntryCountFromVolume(decimal volume)
	{
		if (volume <= 0m)
		return 0;

		if (OrderVolume <= 0m)
		return 1;

		var ratio = volume / OrderVolume;
		if (ratio <= 0m)
		return 0;

		var count = (int)Math.Round(ratio, MidpointRounding.AwayFromZero);
		return Math.Max(1, count);
	}

	private decimal GetPriceStep()
	{
		var step = Security?.PriceStep;
		return step is > 0m ? step.Value : 1m;
	}

	private bool IsWithinTradingWindow(DateTimeOffset time)
	{
		var start = StartHour;
		var end = EndHour;

		if (start < 0 || start > 23 || end < 0 || end > 23 || start >= end)
		{
			if (!_invalidHoursLogged)
			{
				LogWarning($"Invalid trading hours configuration. Start={start}, End={end}.");
				_invalidHoursLogged = true;
			}

			return false;
		}

		_invalidHoursLogged = false;

		var hour = time.Hour;
		return hour >= start && hour < end;
	}
}