在 GitHub 上查看

MA Crossover 多时间框架策略

该策略复现了 MetaTrader 4 平台上的 MA Crossover 专家顾问。它比较来自两个时间框架的移动平均线:当快速均线上穿慢速均线时开多单,下穿时开空单。可以通过参数控制允许的交易方向、可交易时段以及权益保护。止损、止盈和跟踪止损在策略内部执行,以模拟 MQL 版本中的“隐藏”保护逻辑。

交易逻辑

  1. 订阅两个蜡烛序列(当前与上一时间框架),并计算指定类型的移动平均线。
  2. 在比较之前对移动平均线应用配置的柱数偏移。
  3. 忽略未完成的蜡烛,仅在两个指标都形成后才处理信号。
  4. 超出设定的日期/时间窗口或触发权益保护时不进行交易。
  5. 当出现多头交叉时:
    • ClosePositionsOnCross = true,先平掉空头头寸。
    • 如允许做多则开多仓。
  6. 当出现空头交叉时:
    • ClosePositionsOnCross = true,先平掉多头头寸。
    • 如允许做空则开空仓。
  7. 根据入场价的百分比执行止损、止盈与跟踪止损。

参数

参数 说明
AllowedDirection 交易方向限制(LongOnlyShortOnlyLongAndShort)。
ClosePositionsOnCross 新信号出现前是否先平掉反向仓位。
MaType 移动平均线类型(SimpleExponentialSmoothedWeighted)。
CurrentMaPeriod 快速均线周期。
PreviousPeriodAddition 慢速均线的额外周期数(PreviousMaPeriod = CurrentMaPeriod + addition)。
CurrentShift / PreviousShift 对移动平均线值应用的柱数偏移。
CurrentCandleType / PreviousCandleType 计算快慢均线所需的蜡烛数据类型。
StopLossPercent 以入场价百分比表示的止损距离(内部执行)。
TrailingStopPercent 按最佳价格计算的跟踪止损百分比。
TakeProfitPercent 以入场价百分比表示的止盈距离(内部执行)。
StartDay / EndDay 允许交易的星期范围。
StartTime / EndTime 每日可开仓的时间窗口。
ClosePositionsOnMinEquity 触发权益保护时是否平掉所有仓位。
MinimumEquityPercent 相对于初始权益的最低允许百分比。

风险控制

  • 止损、止盈和跟踪止损均在策略内部以市价单执行,不会在交易所挂出保护单。
  • MinimumEquityPercent 会记录启动时的投资组合价值,当权益跌破阈值时触发强制平仓。
  • 使用 Strategy.Volume 设置下单数量,默认值为 1

使用说明

  • 请确保连接器能够提供两个时间框架的蜡烛数据。
  • 即使两个时间框架相同,策略仍会建立两条订阅以保持逻辑对称。
  • 所有风险退出都在蜡烛收盘时通过市价单执行,因此外部不可见。
  • 为了兼容 StockSharp 的高层 API,原始 MQL 程序中的加仓与对冲选项未实现,权益保护也基于组合价值而非保证金。
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;

public class MaCrossoverMultiTimeframeStrategy : Strategy
{
	private readonly StrategyParam<TradeDirectionOptions> _allowedDirection;
	private readonly StrategyParam<bool> _closeOnCross;
	private readonly StrategyParam<MovingAverageTypeOptions> _maType;
	private readonly StrategyParam<int> _currentPeriod;
	private readonly StrategyParam<int> _previousPeriodAdd;
	private readonly StrategyParam<int> _currentShift;
	private readonly StrategyParam<int> _previousShift;
	private readonly StrategyParam<DataType> _currentCandleType;
	private readonly StrategyParam<DataType> _previousCandleType;
	private readonly StrategyParam<decimal> _stopLossPercent;
	private readonly StrategyParam<decimal> _trailingStopPercent;
	private readonly StrategyParam<decimal> _takeProfitPercent;
	private readonly StrategyParam<DayOfWeek> _startDay;
	private readonly StrategyParam<DayOfWeek> _endDay;
	private readonly StrategyParam<TimeSpan> _startTime;
	private readonly StrategyParam<TimeSpan> _endTime;
	private readonly StrategyParam<bool> _closeOnMinEquity;
	private readonly StrategyParam<decimal> _minimumEquityPercent;

	private IIndicator _currentMaIndicator;
	private IIndicator _previousMaIndicator;

	private readonly Queue<decimal> _currentShiftBuffer = new();
	private readonly Queue<decimal> _previousShiftBuffer = new();

	private decimal? _currentMaValue;
	private decimal? _previousMaValue;
	private bool? _wasCurrentAbovePrevious;

	private decimal _entryPrice;
	private decimal _highestPrice;
	private decimal _lowestPrice;
	private decimal _previousPosition;
	private decimal? _initialPortfolioValue;

	/// <summary>
	/// Allowed trade direction.
	/// </summary>
	public TradeDirectionOptions AllowedDirection
	{
		get => _allowedDirection.Value;
		set => _allowedDirection.Value = value;
	}

	/// <summary>
	/// Close opposite positions when a crossover happens.
	/// </summary>
	public bool ClosePositionsOnCross
	{
		get => _closeOnCross.Value;
		set => _closeOnCross.Value = value;
	}

	/// <summary>
	/// Moving average calculation type.
	/// </summary>
	public MovingAverageTypeOptions MaType
	{
		get => _maType.Value;
		set => _maType.Value = value;
	}

	/// <summary>
	/// Period for the current timeframe moving average.
	/// </summary>
	public int CurrentMaPeriod
	{
		get => _currentPeriod.Value;
		set => _currentPeriod.Value = value;
	}

	/// <summary>
	/// Additional length added to the previous moving average.
	/// </summary>
	public int PreviousPeriodAddition
	{
		get => _previousPeriodAdd.Value;
		set => _previousPeriodAdd.Value = value;
	}

	/// <summary>
	/// Shift applied to the current moving average.
	/// </summary>
	public int CurrentShift
	{
		get => _currentShift.Value;
		set => _currentShift.Value = value;
	}

	/// <summary>
	/// Shift applied to the previous moving average.
	/// </summary>
	public int PreviousShift
	{
		get => _previousShift.Value;
		set => _previousShift.Value = value;
	}

	/// <summary>
	/// Candle type for the current moving average.
	/// </summary>
	public DataType CurrentCandleType
	{
		get => _currentCandleType.Value;
		set => _currentCandleType.Value = value;
	}

	/// <summary>
	/// Candle type for the previous moving average.
	/// </summary>
	public DataType PreviousCandleType
	{
		get => _previousCandleType.Value;
		set => _previousCandleType.Value = value;
	}

	/// <summary>
	/// Stop-loss percentage relative to the entry price.
	/// </summary>
	public decimal StopLossPercent
	{
		get => _stopLossPercent.Value;
		set => _stopLossPercent.Value = value;
	}

	/// <summary>
	/// Trailing stop percentage.
	/// </summary>
	public decimal TrailingStopPercent
	{
		get => _trailingStopPercent.Value;
		set => _trailingStopPercent.Value = value;
	}

	/// <summary>
	/// Take-profit percentage relative to the entry price.
	/// </summary>
	public decimal TakeProfitPercent
	{
		get => _takeProfitPercent.Value;
		set => _takeProfitPercent.Value = value;
	}

	/// <summary>
	/// First trading day of the schedule.
	/// </summary>
	public DayOfWeek StartDay
	{
		get => _startDay.Value;
		set => _startDay.Value = value;
	}

	/// <summary>
	/// Last trading day of the schedule.
	/// </summary>
	public DayOfWeek EndDay
	{
		get => _endDay.Value;
		set => _endDay.Value = value;
	}

	/// <summary>
	/// Start time of the trading window.
	/// </summary>
	public TimeSpan StartTime
	{
		get => _startTime.Value;
		set => _startTime.Value = value;
	}

	/// <summary>
	/// End time of the trading window.
	/// </summary>
	public TimeSpan EndTime
	{
		get => _endTime.Value;
		set => _endTime.Value = value;
	}

	/// <summary>
	/// Close all positions when the equity guard is triggered.
	/// </summary>
	public bool ClosePositionsOnMinEquity
	{
		get => _closeOnMinEquity.Value;
		set => _closeOnMinEquity.Value = value;
	}

	/// <summary>
	/// Minimum equity percentage relative to the initial portfolio value.
	/// </summary>
	public decimal MinimumEquityPercent
	{
		get => _minimumEquityPercent.Value;
		set => _minimumEquityPercent.Value = value;
	}

	/// <summary>
	/// Period calculated for the previous moving average.
	/// </summary>
	public int PreviousMaPeriod => Math.Max(1, CurrentMaPeriod + PreviousPeriodAddition);

	/// <summary>
	/// Initializes the strategy parameters.
	/// </summary>
	public MaCrossoverMultiTimeframeStrategy()
	{
		Volume = 1;

		_allowedDirection = Param(nameof(AllowedDirection), TradeDirectionOptions.LongAndShort)
			.SetDisplay("Trade Direction", "Allowed direction for opening positions", "Trading");

		_closeOnCross = Param(nameof(ClosePositionsOnCross), true)
			.SetDisplay("Close on Cross", "Close existing opposite positions when moving averages cross", "Trading");

		_maType = Param(nameof(MaType), MovingAverageTypeOptions.Exponential)
			.SetDisplay("MA Type", "Moving average calculation method", "Indicators");

		_currentPeriod = Param(nameof(CurrentMaPeriod), 42)
			.SetGreaterThanZero()
			.SetDisplay("Current MA Period", "Length of the faster moving average", "Indicators")
			
			.SetOptimize(10, 120, 5);

		_previousPeriodAdd = Param(nameof(PreviousPeriodAddition), 10)
			.SetNotNegative()
			.SetDisplay("Previous MA Extra Length", "Additional length added to the slower moving average", "Indicators")
			
			.SetOptimize(0, 50, 5);

		_currentShift = Param(nameof(CurrentShift), 0)
			.SetNotNegative()
			.SetDisplay("Current MA Shift", "Number of bars to shift the faster moving average", "Indicators");

		_previousShift = Param(nameof(PreviousShift), 2)
			.SetNotNegative()
			.SetDisplay("Previous MA Shift", "Number of bars to shift the slower moving average", "Indicators");

		_currentCandleType = Param(nameof(CurrentCandleType), TimeSpan.FromMinutes(5).TimeFrame())
			.SetDisplay("Current Candle", "Timeframe used for the faster moving average", "Data");

		_previousCandleType = Param(nameof(PreviousCandleType), TimeSpan.FromMinutes(5).TimeFrame())
			.SetDisplay("Previous Candle", "Timeframe used for the slower moving average", "Data");

		_stopLossPercent = Param(nameof(StopLossPercent), 0m)
			.SetNotNegative()
			.SetDisplay("Stop Loss %", "Stop-loss percentage from the entry price", "Risk")
			
			.SetOptimize(0m, 10m, 1m);

		_trailingStopPercent = Param(nameof(TrailingStopPercent), 0m)
			.SetNotNegative()
			.SetDisplay("Trailing Stop %", "Trailing stop percentage applied to the best price", "Risk")
			
			.SetOptimize(0m, 10m, 1m);

		_takeProfitPercent = Param(nameof(TakeProfitPercent), 0m)
			.SetNotNegative()
			.SetDisplay("Take Profit %", "Take-profit percentage from the entry price", "Risk")
			
			.SetOptimize(0m, 20m, 1m);

		_startDay = Param(nameof(StartDay), DayOfWeek.Monday)
			.SetDisplay("Start Day", "First day when trading is allowed", "Schedule");

		_endDay = Param(nameof(EndDay), DayOfWeek.Friday)
			.SetDisplay("End Day", "Last day when trading is allowed", "Schedule");

		_startTime = Param(nameof(StartTime), TimeSpan.Zero)
			.SetDisplay("Start Time", "Daily time when the strategy begins trading", "Schedule");

		_endTime = Param(nameof(EndTime), new TimeSpan(23, 59, 0))
			.SetDisplay("End Time", "Daily time when the strategy stops opening new trades", "Schedule");

		_closeOnMinEquity = Param(nameof(ClosePositionsOnMinEquity), true)
			.SetDisplay("Close on Equity Guard", "Close positions when equity drops below the threshold", "Risk");

		_minimumEquityPercent = Param(nameof(MinimumEquityPercent), 0m)
			.SetNotNegative()
			.SetDisplay("Minimum Equity %", "Minimum equity percentage relative to the initial value", "Risk")
			
			.SetOptimize(0m, 100m, 5m);
	}

	/// <inheritdoc />
	public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
	{
		if (Security == null)
			yield break;

		yield return (Security, CurrentCandleType);
		if (!Equals(PreviousCandleType, CurrentCandleType))
			yield return (Security, PreviousCandleType);
	}

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

		_currentMaIndicator?.Reset();
		_previousMaIndicator?.Reset();

		_currentShiftBuffer.Clear();
		_previousShiftBuffer.Clear();

		_currentMaValue = null;
		_previousMaValue = null;
		_wasCurrentAbovePrevious = null;

		ResetPositionState();
		_previousPosition = 0m;
		_initialPortfolioValue = null;
	}

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

		_initialPortfolioValue = Portfolio?.CurrentValue;
		// Remember the starting equity for the guard logic.

		_currentMaIndicator = CreateMovingAverage(MaType, CurrentMaPeriod);
		_previousMaIndicator = CreateMovingAverage(MaType, PreviousMaPeriod);

		var currentSubscription = SubscribeCandles(CurrentCandleType);
		// Bind the fast moving average to the current timeframe.
		currentSubscription.Bind(_currentMaIndicator, OnCurrentCandle).Start();

		var previousSubscription = SubscribeCandles(PreviousCandleType);
		// Bind the slow moving average to the configured timeframe.
		previousSubscription.Bind(_previousMaIndicator, OnPreviousCandle).Start();

		var area = CreateChartArea();
		if (area != null)
		{
			DrawCandles(area, currentSubscription);
			DrawIndicator(area, _currentMaIndicator);
			DrawIndicator(area, _previousMaIndicator);
			DrawOwnTrades(area);
		}
	}

	private void OnCurrentCandle(ICandleMessage candle, decimal maValue)
	{
		// Process only completed candles to avoid premature reactions.
		if (candle.State != CandleStates.Finished)
			return;

		if (!_currentMaIndicator.IsFormed)
			return;

		var shifted = ApplyShift(CurrentShift, _currentShiftBuffer, maValue);
		if (shifted == null)
			return;

		_currentMaValue = shifted;

		if (!CheckFreeEquityGuard())
			return;

		ManagePosition(candle);
		TryProcessSignal(candle);
	}

	private void OnPreviousCandle(ICandleMessage candle, decimal maValue)
	{
		// Update the reference moving average from the second timeframe.
		if (candle.State != CandleStates.Finished)
			return;

		if (!_previousMaIndicator.IsFormed)
			return;

		var shifted = ApplyShift(PreviousShift, _previousShiftBuffer, maValue);
		if (shifted == null)
			return;

		_previousMaValue = shifted;
	}

	private void TryProcessSignal(ICandleMessage candle)
	{
		// Ensure that both moving averages are available and trading is allowed.
		if (_currentMaValue == null || _previousMaValue == null)
			return;


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

		var isCurrentAbove = _currentMaValue.Value > _previousMaValue.Value;

		if (_wasCurrentAbovePrevious == null)
		{
			_wasCurrentAbovePrevious = isCurrentAbove;
			return;
		}

		if (_wasCurrentAbovePrevious == isCurrentAbove)
			return;

		if (isCurrentAbove)
		{
			HandleBullishCross(candle);
		}
		else
		{
			HandleBearishCross(candle);
		}

		_wasCurrentAbovePrevious = isCurrentAbove;
	}

	private void HandleBullishCross(ICandleMessage candle)
	{
		// Prevent duplicate entries and respect direction filters.
		if (!IsLongAllowed())
			return;

		if (Position > 0)
			return;

		var volume = Volume;
		if (volume <= 0m)
			volume = 1m;

		if (Position < 0)
		{
			if (!ClosePositionsOnCross)
				return;

			volume += Math.Abs(Position);
		}

		BuyMarket(volume);

	}

	private void HandleBearishCross(ICandleMessage candle)
	{
		// Prevent duplicate entries and respect direction filters.
		if (!IsShortAllowed())
			return;

		if (Position < 0)
			return;

		var volume = Volume;
		if (volume <= 0m)
			volume = 1m;

		if (Position > 0)
		{
			if (!ClosePositionsOnCross)
				return;

			volume += Math.Abs(Position);
		}

		SellMarket(volume);

	}

	private void ManagePosition(ICandleMessage candle)
	{
		// Translate percentage-based risk settings into market exits.
		if (Position == 0 || _entryPrice <= 0m)
			return;

		var stopLoss = StopLossPercent / 100m;
		var takeProfit = TakeProfitPercent / 100m;
		var trailing = TrailingStopPercent / 100m;
		var closePrice = candle.ClosePrice;

		if (Position > 0)
		{
			if (closePrice > _highestPrice)
				_highestPrice = closePrice;

			if (stopLoss > 0m)
			{
				var stopPrice = _entryPrice * (1m - stopLoss);
				if (closePrice <= stopPrice)
				{
					SellMarket(Math.Abs(Position));
	
					return;
				}
			}

			if (takeProfit > 0m)
			{
				var targetPrice = _entryPrice * (1m + takeProfit);
				if (closePrice >= targetPrice)
				{
					SellMarket(Math.Abs(Position));
	
					return;
				}
			}

			if (trailing > 0m && _highestPrice > 0m)
			{
				var trailingPrice = _highestPrice * (1m - trailing);
				if (closePrice <= trailingPrice)
				{
					SellMarket(Math.Abs(Position));
	
					return;
				}
			}
		}
		else if (Position < 0)
		{
			if (_lowestPrice == 0m || closePrice < _lowestPrice)
				_lowestPrice = closePrice;

			if (stopLoss > 0m)
			{
				var stopPrice = _entryPrice * (1m + stopLoss);
				if (closePrice >= stopPrice)
				{
					BuyMarket(Math.Abs(Position));
	
					return;
				}
			}

			if (takeProfit > 0m)
			{
				var targetPrice = _entryPrice * (1m - takeProfit);
				if (closePrice <= targetPrice)
				{
					BuyMarket(Math.Abs(Position));
	
					return;
				}
			}

			if (trailing > 0m && _lowestPrice > 0m)
			{
				var trailingPrice = _lowestPrice * (1m + trailing);
				if (closePrice >= trailingPrice)
				{
					BuyMarket(Math.Abs(Position));
	
					return;
				}
			}
		}
	}

	private bool CheckFreeEquityGuard()
	{
		// Abort new trades if the equity guard has been triggered.
		var threshold = MinimumEquityPercent;
		if (threshold <= 0m)
			return true;

		if (_initialPortfolioValue == null || _initialPortfolioValue <= 0m)
			return true;

		var currentValue = Portfolio?.CurrentValue;
		if (currentValue == null)
			return true;

		var minimumEquity = _initialPortfolioValue.Value * (threshold / 100m);
		if (currentValue.Value > minimumEquity)
			return true;



		if (ClosePositionsOnMinEquity && Position != 0)
		{
			CloseAllPositions();
		}

		return false;
	}

	private void CloseAllPositions()
	{
		// Exit using market orders because the protection stays hidden.
		if (Position > 0)
			SellMarket(Math.Abs(Position));
		else if (Position < 0)
			BuyMarket(Math.Abs(Position));
	}

	private bool IsWithinTradingWindow(DateTimeOffset time)
	{
		var day = time.DayOfWeek;
		var startDay = StartDay;
		var endDay = EndDay;

		var withinDays = startDay <= endDay
			? day >= startDay && day <= endDay
			: day >= startDay || day <= endDay;

		if (!withinDays)
			return false;

		var startTime = StartTime;
		var endTime = EndTime;
		var timeOfDay = time.TimeOfDay;

		return startTime <= endTime
			? timeOfDay >= startTime && timeOfDay <= endTime
			: timeOfDay >= startTime || timeOfDay <= endTime;
	}

	private static decimal? ApplyShift(int shift, Queue<decimal> buffer, decimal value)
	{
		// Maintain a small buffer to emulate the MQL shift parameter.
		if (shift <= 0)
		{
			buffer.Clear();
			return value;
		}

		buffer.Enqueue(value);

		while (buffer.Count > shift + 1)
			buffer.Dequeue();

		return buffer.Count == shift + 1 ? buffer.Peek() : null;
	}

	private static IIndicator CreateMovingAverage(MovingAverageTypeOptions type, int length)
	{
		return type switch
		{
			MovingAverageTypeOptions.Simple => new SimpleMovingAverage { Length = length },
			MovingAverageTypeOptions.Exponential => new ExponentialMovingAverage { Length = length },
			MovingAverageTypeOptions.Smoothed => new SmoothedMovingAverage { Length = length },
			MovingAverageTypeOptions.Weighted => new WeightedMovingAverage { Length = length },
			_ => new SimpleMovingAverage { Length = length },
		};
	}

	private bool IsLongAllowed() => AllowedDirection != TradeDirectionOptions.ShortOnly;

	private bool IsShortAllowed() => AllowedDirection != TradeDirectionOptions.LongOnly;

	private void ResetPositionState()
	{
		_entryPrice = 0m;
		_highestPrice = 0m;
		_lowestPrice = 0m;
		_previousPosition = 0m;
	}

	/// <inheritdoc />
	protected override void OnOwnTradeReceived(MyTrade trade)
	{
		// Update the average entry price once fills arrive.
		base.OnOwnTradeReceived(trade);

		if (trade.Order.Security != Security)
			return;

		var currentPosition = Position;

		if (_previousPosition <= 0m && currentPosition > 0m)
		{
			_entryPrice = trade.Trade.Price;
			_highestPrice = trade.Trade.Price;
			_lowestPrice = trade.Trade.Price;
		}
		else if (_previousPosition >= 0m && currentPosition < 0m)
		{
			_entryPrice = trade.Trade.Price;
			_highestPrice = trade.Trade.Price;
			_lowestPrice = trade.Trade.Price;
		}

		if (currentPosition > 0m && trade.Order.Side == Sides.Buy)
		{
			var totalVolume = Math.Abs(currentPosition);
			var previousVolume = Math.Abs(_previousPosition > 0m ? _previousPosition : 0m);
			var tradeVolume = trade.Trade.Volume;
			if (totalVolume > 0m)
			{
				var weighted = (_entryPrice * previousVolume) + (trade.Trade.Price * tradeVolume);
				_entryPrice = weighted / totalVolume;
			}

			if (trade.Trade.Price > _highestPrice)
				_highestPrice = trade.Trade.Price;
		}
		else if (currentPosition < 0m && trade.Order.Side == Sides.Sell)
		{
			var totalVolume = Math.Abs(currentPosition);
			var previousVolume = Math.Abs(_previousPosition < 0m ? _previousPosition : 0m);
			var tradeVolume = trade.Trade.Volume;
			if (totalVolume > 0m)
			{
				var weighted = (_entryPrice * previousVolume) + (trade.Trade.Price * tradeVolume);
				_entryPrice = weighted / totalVolume;
			}

			if (_lowestPrice == 0m || trade.Trade.Price < _lowestPrice)
				_lowestPrice = trade.Trade.Price;
		}

		_previousPosition = currentPosition;
	}

	/// <inheritdoc />
	protected override void OnPositionReceived(Position position)
	{
		base.OnPositionReceived(position);

		if (Position == 0m)
			ResetPositionState();
	}

	public enum TradeDirectionOptions
	{
		LongOnly,
		ShortOnly,
		LongAndShort
	}

	public enum MovingAverageTypeOptions
	{
		Simple,
		Exponential,
		Smoothed,
		Weighted
	}
}