Ver no GitHub

TurnGrid Strategy

Overview

The TurnGrid Strategy replicates the behaviour of the original MQL5 Expert Advisor TurnGrid.mq5. It constructs a symmetric price grid around the current market price and alternates between long and short orders whenever the price migrates from one grid cell to another. The strategy continuously rebalances open orders to maintain both bullish and bearish exposure until the configured equity target is achieved.

The conversion uses StockSharp's high-level API: candle subscriptions drive the grid updates, market orders handle entries and exits, and risk management is expressed through strategy parameters. All comments have been translated into English and the naming follows StockSharp conventions.

Trading Logic

  1. When the strategy starts it captures the latest candle close and builds a grid containing 4 * GridShares levels. The central level is set to the current price, upper levels scale by 1 + GridDistance, and lower levels scale by 1 - GridDistance.
  2. An initial market buy order is placed at the centre of the grid. Its volume is calculated from the available budget portion (Balance / GridShares) and an incremental stake formula inherited from the MQL version.
  3. Every finished candle updates the current grid index based on the close price. If the index changes:
    • Positions linked to tickets two levels away from the new index are closed (buy tickets below the price are sold, sell tickets above are bought back).
    • New positions are opened to keep both long and short anchors on the active level. If neither side is present, the strategy opens the side with fewer active positions to balance exposure.
  4. Fees are approximated via the FeeRate parameter. Each filled order contributes to a running fee total used when evaluating performance.
  5. When the account equity (after subtracting the accumulated fee estimate) exceeds the initial balance by EquityTakeProfit, the strategy closes the net position and rebuilds the grid around the latest price.

Parameters

Name Description Default
GridDistance Relative distance between adjacent grid levels. 0.01
GridShares Maximum number of concurrent grid positions that can be active. 50
EquityTakeProfit Percentage gain over the initial balance required to reset the grid. 0.02
FeeRate Estimated transaction fee per trade, applied to executed volume. 0.0008
CandleType Candle series used to drive the strategy. 1 minute timeframe

Implementation Notes

  • Candle subscription is handled via SubscribeCandles(CandleType) and the strategy reacts only to finished candles, matching the tick-driven logic of the original EA while keeping compatibility with StockSharp.
  • The grid state is stored in a lightweight array of GridLevel structs containing price anchors, boolean flags, and ticket volumes for deferred closures.
  • Order sizes follow the original incremental capital allocation formula, with additional normalization through the security's VolumeStep, VolumeMin, and VolumeMax settings.
  • Equity-based resets wait for the current net position to close before rebuilding the grid, ensuring clean transitions between trading cycles.

Files

  • CS/TurnGridStrategy.cs – C# implementation of the strategy using StockSharp high-level constructs.
  • README.md – English documentation (this file).
  • README_zh.md – Simplified Chinese documentation.
  • README_ru.md – Russian documentation.
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 trading strategy that mirrors the TurnGrid Expert Advisor logic from MQL5.
/// </summary>
public class TurnGridStrategy : Strategy
{
	private enum TradeDirections
	{
		Buy,
		Sell,
	}

	private struct GridLevel
	{
		public decimal Price;
		public bool HasBuy;
		public bool HasSell;
		public decimal BuyVolumeTicket;
		public decimal SellVolumeTicket;
	}

	private readonly StrategyParam<decimal> _gridDistance;
	private readonly StrategyParam<int> _gridShares;
	private readonly StrategyParam<decimal> _equityTakeProfit;
	private readonly StrategyParam<decimal> _feeRate;
	private readonly StrategyParam<DataType> _candleType;

	private GridLevel[] _grid;
	private int _currentIndex;
	private decimal _openBudget;
	private decimal _openMoneyIncrement;
	private int _buyCount;
	private int _sellCount;
	private decimal _lastPrice;
	private decimal _totalFee;
	private decimal _initialBalance;
	private bool _resetRequested;
	private decimal _resetPrice;

	public decimal GridDistance
	{
		get => _gridDistance.Value;
		set => _gridDistance.Value = value;
	}

	public int GridShares
	{
		get => _gridShares.Value;
		set => _gridShares.Value = value;
	}

	public decimal EquityTakeProfit
	{
		get => _equityTakeProfit.Value;
		set => _equityTakeProfit.Value = value;
	}

	public decimal FeeRate
	{
		get => _feeRate.Value;
		set => _feeRate.Value = value;
	}

	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	public TurnGridStrategy()
	{
		_gridDistance = Param(nameof(GridDistance), 0.01m)
			.SetDisplay("Grid Distance", "Relative distance between grid levels", "Grid");
		_gridShares = Param(nameof(GridShares), 50)
			.SetDisplay("Max Grid Positions", "Maximum number of open grid entries", "Grid");
		_equityTakeProfit = Param(nameof(EquityTakeProfit), 0.02m)
			.SetDisplay("Equity Take Profit", "Equity growth ratio that triggers a reset", "Risk");
		_feeRate = Param(nameof(FeeRate), 0.0008m)
			.SetDisplay("Fee Rate", "Estimated transaction fee per trade", "Costs");
		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(1).TimeFrame())
			.SetDisplay("Candle Type", "Candle type used to drive the grid", "Data");
	}

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

		_grid = null;
		_currentIndex = 0;
		_openBudget = 0m;
		_openMoneyIncrement = 0m;
		_buyCount = 0;
		_sellCount = 0;
		_lastPrice = 0m;
		_totalFee = 0m;
		_initialBalance = 0m;
		_resetRequested = false;
		_resetPrice = 0m;
	}

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

		var subscription = SubscribeCandles(CandleType);
		subscription
			.Bind(ProcessCandle)
			.Start();

		var area = CreateChartArea();
		if (area != null)
		{
			DrawCandles(area, subscription);
			DrawOwnTrades(area);
		}
	}

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

		_lastPrice = candle.ClosePrice;

		if (_resetRequested)
		{
			if (!TryCloseNetPosition())
				return;

			InitializeGrid(_resetPrice);
			_resetRequested = false;
		}

		if (_grid == null)
		{
			InitializeGrid(candle.ClosePrice);
			return;
		}

		if (!UpdateCurrentIndex(candle.ClosePrice))
			return;

		if (CheckEquityTarget())
		{
			RequestReset(candle.ClosePrice);
			return;
		}

		CloseReachedPositions();
		ManageOpenings();
	}

	private void InitializeGrid(decimal price)
	{
		if (price <= 0m)
			return;

		var shares = Math.Max(1, GridShares);
		var size = shares * 4;

		_grid = new GridLevel[size];
		_currentIndex = shares * 2;

		_grid[_currentIndex].Price = price;

		for (var i = _currentIndex + 1; i < size; i++)
		{
			_grid[i].Price = _grid[i - 1].Price * (1m + GridDistance);
		}

		for (var i = _currentIndex - 1; i >= 0; i--)
		{
			_grid[i].Price = _grid[i + 1].Price * (1m - GridDistance);
		}

		_buyCount = 0;
		_sellCount = 0;
		_totalFee = 0m;

		var portfolio = Portfolio;
		_initialBalance = portfolio?.CurrentValue ?? portfolio?.CurrentValue ?? _initialBalance;
		if (_initialBalance <= 0m)
			_initialBalance = shares * price;

		_openBudget = _initialBalance / shares;
		if (_openBudget <= 0m)
			_openBudget = price;

		_openMoneyIncrement = CalculateOpenMoneyIncrement();
		_lastPrice = price;

		TryOpenBuy();
	}

	private bool UpdateCurrentIndex(decimal price)
	{
		if (_grid == null)
			return false;

		var newIndex = _currentIndex;

		while (newIndex + 1 < _grid.Length && price >= _grid[newIndex + 1].Price)
			newIndex++;

		while (newIndex - 1 >= 0 && price <= _grid[newIndex - 1].Price)
			newIndex--;

		if (newIndex == _currentIndex)
			return false;

		_currentIndex = newIndex;
		return true;
	}

	private bool CheckEquityTarget()
	{
		if (_initialBalance <= 0m)
			return false;

		var portfolio = Portfolio;
		var equity = portfolio?.CurrentValue ?? portfolio?.CurrentValue ?? 0m;
		if (equity <= 0m)
			return false;

		return equity - _totalFee > _initialBalance * (1m + EquityTakeProfit);
	}

	private void RequestReset(decimal price)
	{
		_resetRequested = true;
		_resetPrice = price;
		_grid = null;
		_buyCount = 0;
		_sellCount = 0;
		_totalFee = 0m;

		TryCloseNetPosition();
	}

	private bool TryCloseNetPosition()
	{
		if (Position > 0m)
		{
			SellMarket(Position);
			return false;
		}

		if (Position < 0m)
		{
			BuyMarket(Math.Abs(Position));
			return false;
		}

		return true;
	}

	private void CloseReachedPositions()
	{
		if (_grid == null)
			return;

		ref var currentLevel = ref _grid[_currentIndex];

		if (currentLevel.BuyVolumeTicket > 0m)
		{
			SellMarket(currentLevel.BuyVolumeTicket);
			_buyCount = Math.Max(0, _buyCount - 1);

			currentLevel.BuyVolumeTicket = 0m;

			var anchorIndex = _currentIndex - 2;
			if (anchorIndex >= 0)
				_grid[anchorIndex].HasBuy = false;
		}

		if (currentLevel.SellVolumeTicket > 0m)
		{
			BuyMarket(currentLevel.SellVolumeTicket);
			_sellCount = Math.Max(0, _sellCount - 1);

			currentLevel.SellVolumeTicket = 0m;

			var anchorIndex = _currentIndex + 2;
			if (_grid != null && anchorIndex < _grid.Length)
				_grid[anchorIndex].HasSell = false;
		}
	}

	private void ManageOpenings()
	{
		if (_grid == null)
			return;

		ref var level = ref _grid[_currentIndex];

		if (level.HasBuy && !level.HasSell)
		{
			TryOpenSell();
			return;
		}

		if (!level.HasBuy && level.HasSell)
		{
			TryOpenBuy();
			return;
		}

		if (!level.HasBuy && !level.HasSell)
		{
			if (_buyCount > _sellCount)
				TryOpenSell();
			else
				TryOpenBuy();
		}
	}

	private void TryOpenBuy()
	{
		if (_grid == null)
			return;

		if (_buyCount + _sellCount >= GridShares)
			return;

		var volume = CalculateVolume(TradeDirections.Buy);
		if (volume <= 0m)
			return;

		BuyMarket(volume);

		ref var level = ref _grid[_currentIndex];
		level.HasBuy = true;

		var targetIndex = _currentIndex + 2;
		if (targetIndex < _grid.Length)
			_grid[targetIndex].BuyVolumeTicket += volume;

		_buyCount++;
	}

	private void TryOpenSell()
	{
		if (_grid == null)
			return;

		if (_buyCount + _sellCount >= GridShares)
			return;

		var volume = CalculateVolume(TradeDirections.Sell);
		if (volume <= 0m)
			return;

		SellMarket(volume);

		ref var level = ref _grid[_currentIndex];
		level.HasSell = true;

		var targetIndex = _currentIndex - 2;
		if (targetIndex >= 0)
			_grid[targetIndex].SellVolumeTicket += volume;

		_sellCount++;
	}

	private decimal CalculateVolume(TradeDirections direction)
	{
		if (_lastPrice <= 0m)
			return 0m;

		var firstMoney = _openBudget / 10m;
		if (firstMoney <= 0m)
			firstMoney = _lastPrice;

		decimal money;
		switch (direction)
		{
			case TradeDirections.Buy:
				money = firstMoney + _buyCount * _openMoneyIncrement;
				break;
			case TradeDirections.Sell:
				money = firstMoney + _sellCount * _openMoneyIncrement;
				break;
			default:
				money = firstMoney;
				break;
		}

		if (money <= 0m)
			return 0m;

		var volume = money / _lastPrice;
		volume = NormalizeVolume(volume);

		if (volume <= 0m)
			return 0m;

		_totalFee += _lastPrice * volume * FeeRate;
		LogInfo($"Total Fee = {_totalFee:F2}; Grid = {_buyCount + _sellCount} / {GridShares} ({_buyCount}, {_sellCount})");

		return volume;
	}

	private decimal NormalizeVolume(decimal volume)
	{
		var security = Security;
		if (security == null)
			return volume;

		var step = security.VolumeStep ?? 0m;
		if (step > 0m)
			volume = step * Math.Round(volume / step, MidpointRounding.AwayFromZero);

		var min = 0m;
		if (min > 0m && volume < min)
			return 0m;

		var max = decimal.MaxValue;
		if (volume > max)
			volume = max;

		return volume;
	}

	private decimal CalculateOpenMoneyIncrement()
	{
		var halfShares = GridShares / 2m;
		if (halfShares <= 1m)
			return 0m;

		var numerator = _initialBalance / 2m - halfShares / 10m;
		if (numerator <= 0m)
			numerator = _initialBalance / 4m;

		var denominator = halfShares * (halfShares - 1m) / 2m;
		if (denominator <= 0m)
			return 0m;

		return numerator / denominator;
	}
}