在 GitHub 上查看

银河爆炸策略

概述

银河爆炸策略将原版 MT5 网格专家移植到 StockSharp。策略只在收盘后的完整 K 线上工作,利用长期移动平均线来判定方向偏好,并根据价格与历史仓位的距离扩展网格。当价格持续位于移动平均线同侧时逐步加仓,在累积的已实现收益与浮动收益达到目标后一次性平掉全部仓位。

市场逻辑

  1. 方向过滤:比较最新 K 线收盘价与移动平均线。当收盘价低于均线时认为行情看多,高于均线则看空。
  2. 渐进式网格:前八次入场在满足方向条件时立即执行。超过八次后,会根据当前价格与最近一次和第一次入场价的距离决定是否继续加仓。
  3. 间距控制:距离以最小报价步长为单位衡量。价格若远离最近一笔仓位就允许加仓;同时根据与第一笔仓位的距离,决定立即交易、跳过三根 K 线或跳过六根 K 线后再入场。
  4. 利润实现:实时计算已实现收益与未实现收益之和,当总收益超过最小利润目标时,通过一笔市价单关闭全部持仓。

交易管理

  • 下单手数:每次下单都使用配置的交易量。如果信号反向且当前有仓位,会发送一笔市价单先平掉原方向再按需要额外开仓。
  • 仓位跟踪:分别维护多头和空头篮子的平均成本、首笔及末笔入场价,从而复刻原始专家中基于距离的加仓规则。
  • 交易时段过滤:只在设定的开始与结束小时之间交易。策略使用 K 线开盘时间判定是否处于交易窗口,窗口外的信号一律忽略。
  • 安全检查:如果交易时段配置错误(例如开始时间不早于结束时间)会记录警告并跳过交易逻辑。

参数

参数 说明
Order Volume 每次入场使用的交易量,同时用于估算当前打开的网格步数。
Start Hour 交易时段开始的小时,早于该时间的信号被忽略。
End Hour 交易时段结束的小时(不含),晚于该时间的信号被忽略。
Minimal Profit 触发整体平仓的已实现收益与浮动收益之和。
Indent After 8th 第八次入场之后,再次开仓所需与最近一次入场的最小距离(以价格步长计)。
Skip 3 Min 触发“跳过三根 K 线”规则的距离下限。
Skip 3 Max 保持“跳过三根 K 线”规则生效的距离上限。
Skip 6 Max 保持“跳过六根 K 线”规则生效的距离上限。
MA Length 用于判定方向的简单移动平均线长度。
Candle Type 策略订阅的 K 线类型,移动平均与网格逻辑都基于该数据流执行。

实现要点

  • 通过 SubscribeCandles 订阅 K 线并绑定 SimpleMovingAverage 指标,仅处理已经完成的 K 线。
  • OnNewMyTrade 中维护仓位统计,精确记录多空篮子的首尾入场价及平均价格。
  • 根据标的 PriceStep 将参数从“点”转换为实际价格距离,保持与 MT5 原策略一致的配置体验。
  • 遵循项目规范,只使用标量状态变量而不引入额外集合结构。
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;
	}
}