Auf GitHub ansehen

Amstell Grid Strategy

The Amstell Grid Strategy is a C# port of the MetaTrader 5 expert advisor exp_Amstell.mq5. It creates a symmetric buy/sell grid and applies a virtual take profit to individual entries. The conversion follows the StockSharp high-level API guidelines and replaces tick handling with candle processing while keeping the original idea intact.

How It Works

  1. Initialization

    • The strategy subscribes to the configured candle type and starts position protection.
    • An adjusted pip size is calculated from the security's PriceStep and decimal precision. Five-digit and three-digit symbols automatically receive a 10x multiplier, mirroring the MT5 implementation.
  2. First Trade

    • When both the last recorded buy and sell prices are empty (initial launch), a market buy order is sent immediately. This bootstraps the grid exactly like the original expert advisor.
  3. Grid Expansion

    • A new buy is issued whenever the current close price is at least StepPips below the last recorded buy price.
    • A new sell is issued whenever the price is at least StepPips above the last recorded sell price.
    • The strategy internally tracks separate long and short stacks so that alternating orders can coexist even on a netting account. Opposite orders first reduce the other stack before adding new exposure, reproducing the hedging behavior of the MT5 version.
  4. Virtual Take Profit

    • Every open long is monitored independently. When price advances by TakeProfitPips, a market sell is sent for that position's volume only.
    • Every open short is treated similarly in the opposite direction. The take profit is "virtual" because positions are closed programmatically without using broker-side TP orders.
    • After a direction has been fully closed while the opposite side still exists, the corresponding last-deal price is cleared so that the next order in that direction can fire immediately, just as in the original code.
  5. State Tracking

    • The OnOwnTradeReceived handler rebuilds the long/short stacks from executed trades, allowing partial fills and reversals to be handled gracefully.
    • Last buy/sell prices remain cached when both sides are flat so that the grid waits for the required step before re-entering after a full reset.

Parameters

Parameter Default Description
Volume 0.1 Order size used for every market order in both directions.
TakeProfitPips 50 Distance in pips that must be gained before an individual position is closed.
StepPips 15 Gap in pips between consecutive grid orders of the same direction.
CandleType 1 Minute Candle data source used to approximate tick-based logic.

All pip-based settings respect the security's price step and precision. For example, on EURUSD (5 digits) StepPips = 15 corresponds to 0.0015.

Practical Notes

  • The strategy uses candle close prices to emulate the tick-level comparisons found in the MT5 code. For high-frequency operation, decrease the timeframe.
  • No stop-loss exists by default. As with any grid approach, runaway trends can accumulate large exposure. Use conservative volumes and consider session-based supervision.
  • Because take profits are handled virtually, closed trades are immediately reflected in the strategy's PnL without placing visible TP orders at the broker.
  • The implementation leaves cached last prices untouched after both sides flatten. This preserves the original behavior where the grid waits for price displacement before restarting.

Files

  • CS/AmstellGridStrategy.cs – StockSharp strategy implementation with extensive inline comments.
  • README.md, README_ru.md, README_zh.md – Full documentation in English, Russian, and Chinese.

This port is ready for further customization (e.g., money management, risk limits) directly within the StockSharp ecosystem.

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