在 GitHub 上查看

TurnGrid 策略

概述

TurnGrid 策略 复刻自原始的 MQL5 专家顾问 TurnGrid.mq5。策略会在当前价格附近搭建对称的价格网格,当价格穿越网格线时交替开仓多头与空头,从而在整个震荡区间内保持动态平衡的持仓组合,直到账户权益达到设定的目标值。

本次移植使用 StockSharp 的高级 API:通过蜡烛线订阅驱动网格更新,市价单用于开平仓,风险管理以参数形式暴露。所有注释均采用英文,命名风格遵循 StockSharp 规范。

交易逻辑

  1. 策略启动时获取最新蜡烛的收盘价,基于该价格构建包含 4 * GridShares 个层级的网格。中心层级使用当前价格,上方层级按 1 + GridDistance 递增,下方层级按 1 - GridDistance 递减。
  2. 在网格中心立即买入一笔市价单。成交量来源于可用资金 (Balance / GridShares),再叠加 MQL 原版中的逐步加仓公式。
  3. 每根完成的蜡烛都会根据收盘价更新当前所在的网格索引。一旦索引发生变化:
    • 关闭距离当前索引两个层级的挂钩仓位:位于价格下方的买单通过卖出平仓,位于价格上方的卖单通过买入平仓。
    • 为当前层级补充缺失方向的仓位。如果多空都为空,则优先补充仓位数量较少的方向,以保持多空平衡。
  4. 通过 FeeRate 参数对手续费进行估算,每次成交都会把估算的手续费累加到运行中的费用统计。
  5. 当账户权益减去累计费用后超过初始余额的 EquityTakeProfit 比例时,策略会平掉当前净头寸,并以最新价格为中心重建网格。

参数

名称 说明 默认值
GridDistance 相邻网格层级之间的相对价格距离。 0.01
GridShares 网格允许的最大持仓数量。 50
EquityTakeProfit 触发网格重置所需的权益增幅。 0.02
FeeRate 每笔交易的手续费估算系数。 0.0008
CandleType 用于驱动策略的蜡烛线类型。 1 分钟周期

实现细节

  • 通过 SubscribeCandles(CandleType) 订阅蜡烛线,仅处理状态为 Finished 的蜡烛,从而在保持逻辑一致性的同时兼容 StockSharp 事件模型。
  • 网格状态保存在轻量级的 GridLevel 结构体数组中,包含价格锚点、持仓标志以及延迟平仓所需的成交量信息。
  • 下单手数沿用原始 EA 的资金分配公式,并结合交易标的的 VolumeStepVolumeMinVolumeMax 做归一化处理。
  • 当权益条件满足时,策略先通过 TryCloseNetPosition 平掉净头寸,再重建网格,确保不同交易周期之间的衔接干净整洁。

文件

  • CS/TurnGridStrategy.cs – 使用 StockSharp 高级 API 实现的策略代码。
  • README.md – 英文说明。
  • README_zh.md – 简体中文说明(本文)。
  • README_ru.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 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;
	}
}