在 GitHub 上查看

MA 网格策略

概览

本策略是 MetaTrader 5 专家顾问 MAGrid.mq5 的 C# 移植版本。它围绕指数移动平均线(EMA)维护一组对冲的买入和卖出头寸。当价格向上或向下突破预设的网格距离时,策略会在突破方向上开仓,同时从对侧网格中平掉一笔持仓,从而始终让仓位簇围绕 EMA 中心展开。

原始来源

  • MQL 仓库目录: MQL/38303
  • 原始文件: MAGrid.mq5
  • 平台: MetaTrader 5(对冲模式)

交易逻辑

  1. EMA 锚点

    • EMA 周期可配置,默认值为 48。
    • EMA 在所选的蜡烛序列上计算。
    • 网格水平按照 Distance 参数的倍数布置在 EMA 上下。
  2. 网格初始化

    • 网格步数会被强制转换为偶数,以保证上下对称。
    • 通过比较最新收盘价和 EMA 网格水平来确定当前所在网格编号。
    • 策略会对称开立多单和空单,使一半仓位位于 EMA 下方,另一半位于 EMA 上方。
  3. 网格维护

    • 当价格收于上方网格之上时:
      • 网格编号加一。
      • 若仍有多头敞口,则平掉一笔多单。
      • 在新的上方网格开立一笔空单。
    • 当价格收于下方网格之下时:
      • 网格编号减一。
      • 若仍有空头敞口,则平掉一笔空单。
      • 在新的下方网格开立一笔多单。
    • 若任一侧的敞口耗尽,对应的触发条件会被暂时禁用,直到重新开仓。
  4. 订单处理

    • 策略使用内部映射记录订单意图,以区分开仓与平仓成交。
    • 分别维护多头与空头敞口,借此在 StockSharp 的净头寸模型下模拟 MQL 的对冲行为。

参数

名称 默认值 描述
MaPeriod 48 计算 EMA 锚点的周期。
GridAmount 6 网格步数,自动向上取偶数。
Distance 0.005 相邻网格之间的相对距离(例如 0.005 表示 0.5%)。
OrderVolume 0.1 每笔市价单的下单量。
CandleType 日线 用于计算 EMA 并评估信号的蜡烛类型。

风险管理

  • 策略未实现止损或止盈,风险控制主要依赖网格数量与下单量。
  • 由于同时持有多空仓位,净值波动较平稳,但保证金占用会随着网格规模增加。
  • 建议结合账户层面的风险限额(最大回撤、资金占用等)使用。

转换说明

  • C# 实现通过分别跟踪多头与空头敞口来复刻原策略的对冲网格逻辑。
  • 原始代码中依赖账户余额计算下单量的部分,被替换为可配置的 OrderVolume 参数。
  • 数据订阅遵循项目规范,使用 SubscribeCandles().Bind(...) 的高阶 API 完成。
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>
/// Moving average grid strategy converted from the MetaTrader MAGrid expert.
/// It manages a symmetric basket of long and short orders around an EMA-based anchor level.
/// </summary>
public class MaGridStrategy : Strategy
{
	private readonly StrategyParam<decimal> _volumeTolerance;

	private readonly StrategyParam<int> _maPeriod;
	private readonly StrategyParam<int> _gridAmount;
	private readonly StrategyParam<decimal> _distance;
	private readonly StrategyParam<decimal> _orderVolume;
	private readonly StrategyParam<DataType> _candleType;

	private readonly Dictionary<Order, OrderIntents> _orderIntents = new();

	private ExponentialMovingAverage _ema;
	private int _effectiveGridAmount;
	private int _currentGrid;
	private decimal _nextGridPrice;
	private decimal _lastGridPrice;
	private bool _isGridInitialized;
	private decimal _longExposure;
	private decimal _shortExposure;

	private enum OrderIntents
	{
		OpenLong,
		OpenShort,
		CloseLong,
		CloseShort
	}

	/// <summary>
	/// Initializes a new instance of the <see cref="MaGridStrategy"/> class.
	/// </summary>
	public MaGridStrategy()
	{
		_volumeTolerance = Param(nameof(VolumeTolerance), 0.0000001m)
			.SetNotNegative()
			.SetDisplay("Volume Tolerance", "Small tolerance applied when balancing grid exposure.", "Risk");

		_maPeriod = Param(nameof(MaPeriod), 48)
		.SetRange(5, 400)
		.SetDisplay("MA Period", "Exponential moving average length", "Grid")
		;

		_gridAmount = Param(nameof(GridAmount), 6)
		.SetRange(2, 40)
		.SetDisplay("Grid Amount", "Number of grid steps (will be forced to an even value)", "Grid")
		;

		_distance = Param(nameof(Distance), 0.005m)
		.SetGreaterThanZero()
		.SetDisplay("Distance", "Relative spacing between grid levels", "Grid")
		;

		_orderVolume = Param(nameof(OrderVolume), 0.1m)
		.SetGreaterThanZero()
		.SetDisplay("Order Volume", "Volume per grid order", "Risk")
		;

		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(5).TimeFrame())
		.SetDisplay("Candle Type", "Primary candle type used by the strategy", "Data");
	}

	/// <summary>
	/// Small tolerance used when comparing accumulated exposure.
	/// </summary>
	public decimal VolumeTolerance
	{
		get => _volumeTolerance.Value;
		set => _volumeTolerance.Value = value;
	}

	/// <summary>
	/// EMA period used for the anchor level.
	/// </summary>
	public int MaPeriod
	{
		get => _maPeriod.Value;
		set => _maPeriod.Value = value;
	}

	/// <summary>
	/// Total number of grid steps that will be mirrored around the EMA.
	/// </summary>
	public int GridAmount
	{
		get => _gridAmount.Value;
		set => _gridAmount.Value = value;
	}

	/// <summary>
	/// Relative distance between consecutive grid levels.
	/// </summary>
	public decimal Distance
	{
		get => _distance.Value;
		set => _distance.Value = value;
	}

	/// <summary>
	/// Volume submitted with each market order.
	/// </summary>
	public decimal OrderVolume
	{
		get => _orderVolume.Value;
		set => _orderVolume.Value = value;
	}

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

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

		_orderIntents.Clear();
		_ema = null;
		_effectiveGridAmount = 0;
		_currentGrid = 0;
		_nextGridPrice = 0m;
		_lastGridPrice = 0m;
		_isGridInitialized = false;
		_longExposure = 0m;
		_shortExposure = 0m;
	}

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

		_effectiveGridAmount = GetEffectiveGridAmount();
		_currentGrid = 0;
		_nextGridPrice = 0m;
		_lastGridPrice = 0m;
		_isGridInitialized = false;
		_longExposure = 0m;
		_shortExposure = 0m;
		_orderIntents.Clear();

		_ema = new EMA
		{
			Length = MaPeriod
		};

		SubscribeCandles(CandleType)
		.Bind(_ema, ProcessCandle)
		.Start();
	}

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

		if (trade?.Order is not { } order || !_orderIntents.TryGetValue(order, out var intent))
		return;

		var volume = trade.Trade.Volume;

		switch (intent)
		{
		case OrderIntents.OpenLong:
		_longExposure += volume;
		break;
		case OrderIntents.OpenShort:
		_shortExposure += volume;
		break;
		case OrderIntents.CloseLong:
		_longExposure = Math.Max(0m, _longExposure - volume);
		break;
		case OrderIntents.CloseShort:
		_shortExposure = Math.Max(0m, _shortExposure - volume);
		break;
		}

		if (order.Balance <= VolumeTolerance || (order.State == OrderStates.Done || order.State == OrderStates.Failed))
		_orderIntents.Remove(order);
	}

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

		if (_ema?.IsFormed != true)
		return;

		CleanupCompletedOrders();

		if (!_isGridInitialized)
		{
		InitializeGrid(candle.ClosePrice, emaValue);
		return;
		}

		UpdateGridLevels(emaValue);

		if (_nextGridPrice > 0m && candle.ClosePrice >= _nextGridPrice && _nextGridPrice < decimal.MaxValue)
		{
		_currentGrid++;
		CloseLongExposure();
		OpenShortExposure();
		UpdateGridLevels(emaValue);
		}
		else if (_lastGridPrice > 0m && candle.ClosePrice <= _lastGridPrice && _lastGridPrice > decimal.MinValue)
		{
		_currentGrid--;
		CloseShortExposure();
		OpenLongExposure();
		UpdateGridLevels(emaValue);
		}
	}

	private int GetEffectiveGridAmount()
	{
		var amount = GridAmount;
		if (amount < 2)
		amount = 2;

		if (amount % 2 != 0)
		amount++;

		return amount;
	}

	private void InitializeGrid(decimal closePrice, decimal ema)
	{
		_isGridInitialized = true;
		_currentGrid = DetermineInitialGrid(closePrice, ema);

		var half = _effectiveGridAmount / 2;
		var buyCount = Math.Max(0, half - _currentGrid);
		var sellCount = Math.Max(0, _effectiveGridAmount - buyCount);

		for (var i = 0; i < buyCount; i++)
		OpenLongExposure();

		for (var i = 0; i < sellCount; i++)
		OpenShortExposure();

		UpdateGridLevels(ema);
	}

	private int DetermineInitialGrid(decimal price, decimal ema)
	{
		var half = _effectiveGridAmount / 2;
		var distance = Distance;

		if (price < ema)
		{
		for (var i = 1; i <= half; i++)
		{
		var level = ema * (1m - distance * i);
		if (price > level)
		return 1 - i;
		}

		return -half;
		}

		for (var i = 1; i <= half; i++)
		{
		var level = ema * (1m + distance * i);
		if (price < level)
		return i - 1;
		}

		return half;
	}

	private void UpdateGridLevels(decimal ema)
	{
		var distance = Distance;

		if (_currentGrid < _effectiveGridAmount - 1)
		_nextGridPrice = ema * (1m + distance * (1m + _currentGrid));
		else
		_nextGridPrice = 0m;

		if (_currentGrid > 1 - _effectiveGridAmount)
		_lastGridPrice = ema * (1m - distance * (1m - _currentGrid));
		else
		_lastGridPrice = 0m;

		if (_longExposure <= VolumeTolerance)
		_nextGridPrice = decimal.MaxValue;

		if (_shortExposure <= VolumeTolerance)
		_lastGridPrice = decimal.MinValue;
	}

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

		RegisterOrder(BuyMarket(OrderVolume), OrderIntents.OpenLong);
	}

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

		RegisterOrder(SellMarket(OrderVolume), OrderIntents.OpenShort);
	}

	private void CloseLongExposure()
	{
		if (_longExposure <= VolumeTolerance)
		return;

		var volume = Math.Min(OrderVolume, _longExposure);
		if (volume <= VolumeTolerance)
		return;

		RegisterOrder(SellMarket(volume), OrderIntents.CloseLong);
	}

	private void CloseShortExposure()
	{
		if (_shortExposure <= VolumeTolerance)
		return;

		var volume = Math.Min(OrderVolume, _shortExposure);
		if (volume <= VolumeTolerance)
		return;

		RegisterOrder(BuyMarket(volume), OrderIntents.CloseShort);
	}

	private void RegisterOrder(Order order, OrderIntents intent)
	{
		if (order == null)
		return;

		_orderIntents[order] = intent;
	}

	private void CleanupCompletedOrders()
	{
		if (_orderIntents.Count == 0)
		return;

		List<Order> completed = null;

		foreach (var pair in _orderIntents)
		{
		if (!(pair.Key.State == OrderStates.Done || pair.Key.State == OrderStates.Failed))
		continue;

		completed ??= new List<Order>();
		completed.Add(pair.Key);
		}

		if (completed == null)
		return;

		foreach (var order in completed)
		_orderIntents.Remove(order);
	}
}