Ver no GitHub

Estratégia TurnGrid

Visão geral

A Estratégia TurnGrid replica o comportamento do MQL5 Expert Advisor TurnGrid.mq5 original. Ele constrói uma grade de preços simétrica em torno do preço de mercado atual e alterna entre pedidos longos e curtos sempre que o preço migra de uma célula da grade para outra. A estratégia reequilibra continuamente as ordens abertas para manter a exposição de alta e de baixa até que a meta de capital configurada seja alcançada.

A conversão usa o API de alto nível de StockSharp: as assinaturas de velas impulsionam as atualizações da grade, as ordens de mercado tratam de entradas e saídas e o gerenciamento de risco é expresso por meio de parâmetros de estratégia. Todos os comentários foram traduzidos para o inglês e a nomenclatura segue as convenções StockSharp.

Lógica de negociação

  1. Quando a estratégia é iniciada, ela captura o último fechamento da vela e constrói uma grade contendo 4 * GridShares níveis. O nível central é definido para o preço atual, os níveis superiores são escalonados em 1 + GridDistance e os níveis inferiores são escalonados em 1 - GridDistance.
  2. Uma ordem inicial de compra de mercado é colocada no centro da grade. Seu volume é calculado a partir da parcela do orçamento disponível (Balance / GridShares) e de uma fórmula de participação incremental herdada da versão MQL.
  3. Cada vela finalizada atualiza o índice da grade atual com base no preço de fechamento. Se o índice mudar:
    • As posições vinculadas aos tickets a dois níveis do novo índice são fechadas (os tickets comprados abaixo do preço são vendidos, os tickets vendidos acima são recomprados).
    • Novas posições são abertas para manter as âncoras longas e curtas no nível ativo. Se nenhum dos lados estiver presente, a estratégia abre o lado com menos posições ativas para equilibrar a exposição.
  4. As taxas são aproximadas por meio do parâmetro FeeRate. Cada pedido atendido contribui para uma taxa total usada na avaliação de desempenho.
  5. Quando o patrimônio da conta (após subtrair a estimativa de taxa acumulada) excede o saldo inicial em EquityTakeProfit, a estratégia fecha a posição líquida e reconstrói a grade em torno do preço mais recente.

Parâmetros

Nome Descrição Padrão
GridDistance Distância relativa entre níveis de grade adjacentes. 0.01
GridShares Número máximo de posições de grade simultâneas que podem estar ativas. 50
EquityTakeProfit Ganho percentual sobre o saldo inicial necessário para zerar a rede. 0.02
FeeRate Taxa de transação estimada por negociação, aplicada ao volume executado. 0.0008
CandleType Série de velas usada para impulsionar a estratégia. Período de 1 minutos

Notas de implementação

  • A assinatura de velas é gerenciada por meio de SubscribeCandles(CandleType) e a estratégia reage apenas às velas finalizadas, correspondendo à lógica orientada por ticks do EA original, mantendo a compatibilidade com StockSharp.
  • O estado da grade é armazenado em uma matriz leve de estruturas GridLevel contendo âncoras de preços, sinalizadores booleanos e volumes de tickets para fechamentos adiados.
  • Os tamanhos dos pedidos seguem a fórmula original de alocação de capital incremental, com normalização adicional por meio das configurações VolumeStep, VolumeMin e VolumeMax do título.
  • As redefinições baseadas em ações aguardam o fechamento da posição líquida atual antes de reconstruir a rede, garantindo transições limpas entre os ciclos de negociação.

Arquivos

  • CS/TurnGridStrategy.cs – implementação em C# da estratégia usando StockSharp construções de alto nível.
  • README.md – Documentação em inglês (este arquivo).
  • README_zh.md – Documentação em chinês simplificado.
  • README_ru.md – Documentação russa.
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;
	}
}