在 GitHub 上查看

Amstell 网格策略

Amstell 网格策略是 MetaTrader 5 专家顾问 exp_Amstell.mq5 的 C# 版本,专门为 StockSharp 平台编写。策略构建对称的买入/卖出网格,并为每个仓位应用虚拟止盈。实现完全遵循 AGENTS.md 的要求,使用蜡烛数据替代 MT5 中的 OnTick,同时保持原始交易思路。

工作机制

  1. 初始化

    • 订阅所选蜡烛类型的数据,并调用 StartProtection() 启动仓位保护。
    • 根据证券的 PriceStep 和小数位数计算点值。对于 3 位或 5 位报价的品种,点值会自动乘以 10,与 MT5 的处理保持一致。
  2. 首笔交易

    • 当最后一次买入/卖出价格都为空(首次启动)时,会立即发送一笔市价买单,用来激活网格,这与原始 EA 的行为一致。
  3. 网格扩展

    • 当当前收盘价相对最后一次买入价至少下移 StepPips 个点时,追加一笔买单。
    • 当价格相对最后一次卖出价至少上移 StepPips 个点时,追加一笔卖单。
    • 策略内部维护独立的多头与空头列表。相反方向的订单会优先抵消已有仓位,剩余数量才会转化为新的网格层,从而在净头寸账户上模拟 MT5 的对冲逻辑。
  4. 虚拟止盈

    • 对每一笔多头持仓单独监控,当价格上行 TakeProfitPips 个点时,以相同数量市价卖出平仓。
    • 空头仓位采用镜像逻辑:当价格下行 TakeProfitPips 个点时,以相同数量市价买入平仓。
    • 止盈由代码触发,交易账户中不会挂出真实的 TP 订单,因此被称为“虚拟止盈”。
    • 当某一方向的仓位全部关闭而另一方向仍然存在时,对应的最后成交价会被清零,使下一次同方向下单能够立即触发,行为与原脚本一致。
  5. 状态管理

    • OnOwnTradeReceived 事件根据成交回报重建多空队列,可正确处理部分成交与反向操作。
    • 当两边都清仓后,最近一次买入/卖出价格依旧保留,这意味着策略会等待价格重新偏离设定的步长,再次启动网格。

参数说明

参数 默认值 说明
Volume 0.1 每次下单的数量。
TakeProfitPips 50 单笔仓位达到多少点收益时触发虚拟止盈。
StepPips 15 同方向相邻网格之间的点数间隔。
CandleType 1 分钟 使用哪种蜡烛数据来近似原来的逐笔逻辑。

所有基于点数的设置都会自动转换为价格单位。例如在 5 位报价的 EURUSD 上,StepPips = 15 等同于 0.0015。

实战提示

  • 若需要更细腻的反应速度,可以选择更小的蜡烛周期;策略仍然在蜡烛收盘时评估条件。
  • 策略没有内置止损。与所有网格系统一样,单边强势行情可能累积较大头寸,请务必控制仓位并结合资金管理。
  • 虚拟止盈会立即在策略端记录盈亏,而无需向券商提交止盈委托。
  • 当网格完全平仓后,保留的最后成交价能够迫使策略等待价格重新偏离步长,再次入场,忠实复刻原策略的节奏。

文件列表

  • CS/AmstellGridStrategy.cs — StockSharp 平台上的策略实现,包含详细英文注释。
  • README.mdREADME_ru.mdREADME_zh.md — 英文、俄文、中文三种语言的完整说明。

该移植版本可作为进一步研究与扩展的基础,例如加入风险控制、优化资金分配或叠加额外过滤条件。

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;
	}
}