Открыть на GitHub

Стратегия Carbophos Grid

Обзор

Стратегия Carbophos Grid — это прямая конверсия советника MetaTrader 5 «Carbophos». Она постоянно поддерживает симметричную лестницу отложенных ордеров вокруг текущих цен bid/ask, отслеживает совокупную плавающую прибыль по всей сетке и закрывает позиции при достижении цели по прибыли либо при превышении допустимого убытка. После полной фиксации позиции и отмены оставшихся заявок сетка автоматически строится заново.

Логика торговли

  1. При старте стратегии, когда нет открытых позиций и активных ордеров, рассчитывается расстояние между уровнями сетки на основе шага в пунктах и точности котировки инструмента. Затем над лучшей ценой покупки размещается заданное количество (по умолчанию пять) заявок SellLimit, а под лучшей ценой продажи — такое же количество заявок BuyLimit.
  2. При исполнении любого ордера стратегия начинает контролировать позицию по каждой новой котировке Level1. Плавающий PnL вычисляется как разница между текущей ценой закрытия (для лонга — лучшая цена bid, для шорта — лучшая цена ask) и средневзвешенной ценой входа.
  3. Если плавающая прибыль превышает установленную цель или плавающий убыток достигает предельного значения, стратегия отправляет рыночный ордер для закрытия позиции и отменяет все оставшиеся лимитные заявки. После этого внутренний флаг сбрасывается, и при следующем обновлении цены сетка строится заново.
  4. Если все ордера были исполнены, но суммарная позиция вернулась к нулю (например, цена прошла сетку в обе стороны), следующее обновление Level1 приведёт к постановке новой сетки.

Параметры

Параметр Описание
ProfitTarget Уровень плавающей прибыли (в деньгах), при котором закрывается вся сетка.
MaxLoss Максимально допустимый плавающий убыток (в деньгах), запускающий аварийное закрытие.
StepPips Расстояние между соседними уровнями сетки в пунктах. Внутри стратегии переводится в цену с учётом PriceStep инструмента.
OrdersPerSide Количество отложенных ордеров над и под текущей ценой.
OrderVolume Объём каждой лимитной заявки сетки.

Для всех параметров заданы диапазоны оптимизации, что облегчает исследование стратегии в оптимизаторе StockSharp.

Управление рисками и защита

Стратегия однократно вызывает StartProtection() и дополнительно контролирует жёсткие денежные уровни прибыли/убытка. Расчёт плавающего результата опирается на значения PriceStep и StepPrice. При срабатывании одного из порогов выполняется рыночное закрытие позиции и вызывается CancelActiveOrders() для отмены всех активных ордеров, после чего сетка может быть построена заново.

Особенности конверсии

  • В оригинальном MQL5 для инструментов с тремя или пятью знаками после запятой точка пересчитывалась отдельно. Порт StockSharp выполняет ту же корректировку, умножая PriceStep на 10, когда Security.Decimals равен 3 или 5.
  • MetaTrader суммирует прибыль, комиссию и своп по «магическому» номеру. В версии StockSharp плавающий PnL пересчитывается по текущим ценам bid/ask и средневзвешенной цене входа, поэтому отдельная обработка комиссий не требуется.
  • Управление ордерами реализовано через высокоуровневые методы BuyLimit, SellLimit, BuyMarket, SellMarket и CancelActiveOrders, что соответствует требованиям репозитория.
  • Обновление состояния происходит только по событиям Level1, что повторяет поведение OnTick в MetaTrader и не требует дополнительных таймеров или самописных структур данных.

Использование

  1. Перед запуском стратегии укажите нужные Security и Portfolio.
  2. При необходимости скорректируйте параметры под характеристики инструмента и допустимый риск.
  3. Запустите стратегию: она подпишется на Level1, построит первую сетку после получения лучшего bid и ask и дальше будет управлять позициями автоматически.
  4. Следите за сообщениями в журнале (например, «Profit target reached» или «Maximum loss reached»), чтобы понимать, когда сетка была закрыта и построена заново.

Убедитесь, что выбранный инструмент предоставляет поток котировок с лучшим bid/ask; без этих данных сетка не будет построена.

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 converted from the Carbophos MetaTrader 5 expert advisor.
/// Simulates symmetric grid levels and manages profit and loss on the aggregated position.
/// </summary>
public class CarbophosGridStrategy : Strategy
{
	private readonly StrategyParam<decimal> _profitTarget;
	private readonly StrategyParam<decimal> _maxLoss;
	private readonly StrategyParam<int> _stepPips;
	private readonly StrategyParam<int> _ordersPerSide;
	private readonly StrategyParam<decimal> _orderVolume;
	private readonly StrategyParam<DataType> _candleType;

	private decimal? _entryPrice;
	private decimal _gridCenterPrice;
	private bool _gridPlaced;
	private int _cooldownRemaining;

	private readonly List<decimal> _buyLevels = new();
	private readonly List<decimal> _sellLevels = new();

	/// <summary>
	/// Floating profit level (in absolute price * volume) that triggers closing of all positions.
	/// </summary>
	public decimal ProfitTarget
	{
		get => _profitTarget.Value;
		set => _profitTarget.Value = value;
	}

	/// <summary>
	/// Maximum allowed floating loss before the grid is closed.
	/// </summary>
	public decimal MaxLoss
	{
		get => _maxLoss.Value;
		set => _maxLoss.Value = value;
	}

	/// <summary>
	/// Distance between grid levels expressed in pips.
	/// </summary>
	public int StepPips
	{
		get => _stepPips.Value;
		set => _stepPips.Value = value;
	}

	/// <summary>
	/// Number of limit orders to place above and below the market price.
	/// </summary>
	public int OrdersPerSide
	{
		get => _ordersPerSide.Value;
		set => _ordersPerSide.Value = value;
	}

	/// <summary>
	/// Volume for each grid level order.
	/// </summary>
	public decimal OrderVolume
	{
		get => _orderVolume.Value;
		set => _orderVolume.Value = value;
	}

	/// <summary>
	/// Candle type used by the strategy.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	/// <summary>
	/// Initializes <see cref="CarbophosGridStrategy"/>.
	/// </summary>
	public CarbophosGridStrategy()
	{
		_profitTarget = Param(nameof(ProfitTarget), 500m)
			.SetGreaterThanZero()
			.SetDisplay("Profit Target", "Floating profit target in money", "Risk")
			.SetOptimize(100m, 1000m, 50m);

		_maxLoss = Param(nameof(MaxLoss), 100m)
			.SetGreaterThanZero()
			.SetDisplay("Max Loss", "Maximum floating loss before closing", "Risk")
			.SetOptimize(50m, 500m, 25m);

		_stepPips = Param(nameof(StepPips), 2000)
			.SetGreaterThanZero()
			.SetDisplay("Step (pips)", "Distance between grid levels in pips", "Grid")
			.SetOptimize(10, 150, 10);

		_ordersPerSide = Param(nameof(OrdersPerSide), 1)
			.SetGreaterThanZero()
			.SetDisplay("Orders Per Side", "Number of pending orders on each side", "Grid")
			.SetOptimize(1, 10, 1);

		_orderVolume = Param(nameof(OrderVolume), 1m)
			.SetGreaterThanZero()
			.SetDisplay("Order Volume", "Volume for each pending order", "Trading");

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
			.SetDisplay("Candle Type", "Type of candles to use", "General");
	}

	/// <inheritdoc />
	public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
	{
		return [(Security, CandleType)];
	}

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

		_entryPrice = null;
		_gridCenterPrice = 0m;
		_gridPlaced = false;
		_cooldownRemaining = 0;
		_buyLevels.Clear();
		_sellLevels.Clear();
	}

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

		var currentPrice = candle.ClosePrice;

		// Check if any grid levels were hit by this candle
		CheckGridFills(candle);

		// Check profit/loss on position
		if (Position != 0 && _entryPrice is decimal entry)
		{
			var floatingPnL = (currentPrice - entry) * Position;

			if (floatingPnL >= ProfitTarget)
			{
				CloseAll("Profit target reached.");
				return;
			}

			if (floatingPnL <= -MaxLoss)
			{
				CloseAll("Maximum loss reached.");
				return;
			}
		}

		// Cooldown after closing
		if (_cooldownRemaining > 0)
		{
			_cooldownRemaining--;
			return;
		}

		// Place grid if none is active
		if (!_gridPlaced || (Position == 0 && _buyLevels.Count == 0 && _sellLevels.Count == 0))
		{
			PlaceGrid(currentPrice);
		}
	}

	private void PlaceGrid(decimal centerPrice)
	{
		_buyLevels.Clear();
		_sellLevels.Clear();

		var stepSize = GetGridStep();
		if (stepSize <= 0m || centerPrice <= 0m)
			return;

		for (var i = 1; i <= OrdersPerSide; i++)
		{
			var offset = stepSize * i;
			var buyPrice = centerPrice - offset;
			var sellPrice = centerPrice + offset;

			if (buyPrice > 0m)
				_buyLevels.Add(buyPrice);

			_sellLevels.Add(sellPrice);
		}

		_gridCenterPrice = centerPrice;
		_gridPlaced = true;
	}

	private void CheckGridFills(ICandleMessage candle)
	{
		// Check buy levels (price goes down to the level)
		for (var i = _buyLevels.Count - 1; i >= 0; i--)
		{
			if (i >= _buyLevels.Count) continue;
			if (candle.LowPrice <= _buyLevels[i])
			{
				var level = _buyLevels[i];
				BuyMarket();
				UpdateEntryPrice(level, OrderVolume, true);
				try { _buyLevels.RemoveAt(i); } catch { }
			}
		}

		// Check sell levels (price goes up to the level)
		for (var i = _sellLevels.Count - 1; i >= 0; i--)
		{
			if (i >= _sellLevels.Count) continue;
			if (candle.HighPrice >= _sellLevels[i])
			{
				var level = _sellLevels[i];
				SellMarket();
				UpdateEntryPrice(level, OrderVolume, false);
				try { _sellLevels.RemoveAt(i); } catch { }
			}
		}
	}

	private void UpdateEntryPrice(decimal fillPrice, decimal volume, bool isBuy)
	{
		if (_entryPrice is not decimal existingEntry || Position == 0)
		{
			_entryPrice = fillPrice;
			return;
		}

		// Weighted average entry price calculation
		var existingPos = Position;
		var newPos = isBuy ? existingPos + volume : existingPos - volume;

		if (newPos == 0)
		{
			_entryPrice = null;
			return;
		}

		// Only update if adding to position in same direction
		if ((isBuy && existingPos > 0) || (!isBuy && existingPos < 0))
		{
			var totalVolume = Math.Abs(existingPos) + volume;
			_entryPrice = (existingEntry * Math.Abs(existingPos) + fillPrice * volume) / totalVolume;
		}
		else
		{
			// Reducing position - keep same entry price
			if (Math.Abs(newPos) > 0)
				_entryPrice = existingEntry;
			else
				_entryPrice = null;
		}
	}

	private void CloseAll(string reason)
	{
		if (Position > 0)
			SellMarket();
		else if (Position < 0)
			BuyMarket();

		_buyLevels.Clear();
		_sellLevels.Clear();
		_gridPlaced = false;
		_entryPrice = null;
		_cooldownRemaining = 10;

		LogInfo(reason);
	}

	private decimal GetGridStep()
	{
		var security = Security;

		var priceStep = security?.PriceStep ?? 0m;
		if (priceStep <= 0m)
			priceStep = 0.01m;

		var decimals = security?.Decimals ?? 2;
		var multiplier = (decimals == 3 || decimals == 5) ? 10m : 1m;
		return StepPips * priceStep * multiplier;
	}
}