Открыть на GitHub

Стратегия Backbone

Данная реализация переносит эксперта Backbone с платформы MetaTrader 5 на высокоуровневый API StockSharp. Алгоритм поочерёдно формирует длинные и короткие серии сделок, наращивает позицию по доле риска и защищает её фиксированными целями, дополняя сопровождением по трейлинг-стопу.

Основная идея

  1. Определение стартового направления. После запуска стратегия отслеживает максимумы и минимумы. Импульс, превышающий дистанцию TrailingStopPips, задаёт первое рабочее направление.
  2. Циклы по направлениям. Пока серия активна, торгуется только одна сторона. После полного закрытия всех позиций счётчик сбрасывается и логика готовится к сделкам в противоположную сторону.
  3. Масштабирование по риску. Каждое добавление позиций рассчитывает новый объём на основе капитала счёта, параметра MaxRisk, лимита MaxTrades и величины стоп-лосса — аналогично функции Vol в оригинальном советнике.
  4. Защита позиции. Стоп-лосс и тейк-профит пересчитываются для усреднённой цены текущей серии. Трейлинг-стоп подтягивает защитный уровень, как только прибыль превышает заданный порог.

Параметры

Параметр Значение по умолчанию Назначение
MaxRisk 0.5 Доля капитала, доступная для одной направленной серии.
MaxTrades 10 Максимальное количество усреднений в рамках цикла.
TakeProfitPips 170 Расстояние до тейк-профита в пипсах.
StopLossPips 40 Расстояние до защитного стоп-ордера в пипсах.
TrailingStopPips 300 Порог для определения направления и для трейлинга.
CandleType 5-минутные свечи Тип свечей, по которым выполняются расчёты.

Пояснение по пипсу. Размер шага автоматически подстраивается по PriceStep. Для инструментов с 3 или 5 десятичными знаками применяется коэффициент ×10, как в MetaTrader.

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

  1. Обрабатываются только закрытые свечи, при этом стратегия должна быть готова к торгам (IsFormedAndOnlineAndAllowTrading).
  2. Пока направление не выбрано, обновляются экстремумы и проверяется пробой на величину TrailingStopPips.
  3. Для длинной серии:
    • Открывается первая покупка, если прошлый цикл был коротким и позиция отсутствует.
    • Дополнительные покупки разрешены, если текущая серия уже длинная и количество сделок меньше MaxTrades.
    • Выход осуществляется по тейк-профиту, стоп-лоссу либо после подтяжки трейлинга.
  4. Для короткой серии выполняются зеркальные условия.
  5. После закрытия серии счётчики сбрасываются, и стратегия готовится к обратному движению.

Расчёт объёма

Объём каждой новой сделки вычисляется по формуле:

qty = equity * fraction / (pipSize * stopLoss)
где fraction = 1 / (MaxTrades / MaxRisk - openTrades)

Полученный объём округляется по шагу лота и ограничивается минимальным/максимальным значением. Если расчёт меньше допустимого минимума, используется минимальный объём. При отсутствии данных по капиталу применяется стандартный объём стратегии.

Управление выходом

  • Стоп-лосс и тейк-профит. После каждого усреднения пересчитываются относительно средневзвешенной цены текущей серии.
  • Трейлинг-стоп. Для длинных позиций стоп переносится на Close - TrailingStopPips * pipSize, когда плавающая прибыль превышает заданную дистанцию. Для коротких позиций используется зеркальная логика.

Особенности

  • StockSharp работает в модели неттинга, поэтому управление ведётся по совокупной позиции, а не по отдельным ордерам. Алгоритм повторяет оригинальную смену направлений, адаптированную под неттинговый учёт.
  • Расчёты выполняются на закрытии свечи. Внутридневные движения меньше диапазона свечи не анализируются.
  • Для корректной работы необходимо выбрать таймфрейм и инструмент с достаточным объёмом исторических данных, чтобы сформировать стартовые экстремумы.
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;

using System.Globalization;

namespace StockSharp.Samples.Strategies;

/// <summary>
/// Backbone strategy converted from the MQL5 expert advisor.
/// Alternates long and short series with risk-based scaling, stop-loss, take-profit, and trailing stop management.
/// </summary>
public class BackboneStrategy : Strategy
{
	private readonly StrategyParam<decimal> _maxRisk;
	private readonly StrategyParam<int> _maxTrades;
	private readonly StrategyParam<decimal> _takeProfitPips;
	private readonly StrategyParam<decimal> _stopLossPips;
	private readonly StrategyParam<decimal> _trailingStopPips;
	private readonly StrategyParam<DataType> _candleType;

	private decimal _bidMax;
	private decimal _askMin;
	private int _lastDirection;
	private int _currentDirection;
	private int _longCount;
	private int _shortCount;
	private decimal _longAveragePrice;
	private decimal _shortAveragePrice;
	private decimal? _longStop;
	private decimal? _longTake;
	private decimal? _shortStop;
	private decimal? _shortTake;
	private decimal _adjustedPoint;

	/// <summary>
	/// Maximum total risk fraction shared across all positions.
	/// </summary>
	public decimal MaxRisk
	{
		get => _maxRisk.Value;
		set => _maxRisk.Value = value;
	}

	/// <summary>
	/// Maximum number of stacked entries in one direction.
	/// </summary>
	public int MaxTrades
	{
		get => _maxTrades.Value;
		set => _maxTrades.Value = value;
	}

	/// <summary>
	/// Take-profit distance expressed in pips.
	/// </summary>
	public decimal TakeProfitPips
	{
		get => _takeProfitPips.Value;
		set => _takeProfitPips.Value = value;
	}

	/// <summary>
	/// Stop-loss distance expressed in pips.
	/// </summary>
	public decimal StopLossPips
	{
		get => _stopLossPips.Value;
		set => _stopLossPips.Value = value;
	}

	/// <summary>
	/// Trailing stop activation distance in pips.
	/// </summary>
	public decimal TrailingStopPips
	{
		get => _trailingStopPips.Value;
		set => _trailingStopPips.Value = value;
	}

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

	/// <summary>
	/// Initializes a new instance of the <see cref="BackboneStrategy"/> class.
	/// </summary>
	public BackboneStrategy()
	{
		_maxRisk = Param(nameof(MaxRisk), 0.5m)
			.SetGreaterThanZero()
			.SetDisplay("Max Risk", "Maximum risk fraction shared across trades", "Risk");

		_maxTrades = Param(nameof(MaxTrades), 1)
			.SetGreaterThanZero()
			.SetDisplay("Max Trades", "Maximum number of layered entries", "Risk");

		_takeProfitPips = Param(nameof(TakeProfitPips), 170m)
			.SetGreaterThanZero()
			.SetDisplay("Take Profit", "Distance for the take-profit target (pips)", "Risk");

		_stopLossPips = Param(nameof(StopLossPips), 40m)
			.SetGreaterThanZero()
			.SetDisplay("Stop Loss", "Distance for the protective stop (pips)", "Risk");

		_trailingStopPips = Param(nameof(TrailingStopPips), 300m)
			.SetGreaterThanZero()
			.SetDisplay("Trailing Stop", "Distance for the trailing stop activation (pips)", "Risk");

		_candleType = Param(nameof(CandleType), TimeSpan.FromDays(1).TimeFrame())
			.SetDisplay("Candle Type", "Type of candles used for analysis", "General");
	}

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

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

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

		ResetState();
		_adjustedPoint = GetAdjustedPoint();

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

	private void ProcessCandle(ICandleMessage candle)
	{
		// Wait for completed candles only.
		if (candle.State != CandleStates.Finished)
			return;

		// Trade only when the strategy is fully operational.
		// removed IsFormedAndOnlineAndAllowTrading for backtesting

		if (_adjustedPoint <= 0m)
			_adjustedPoint = GetAdjustedPoint();

		UpdateExtremeLevels(candle);

		if (_currentDirection == 1)
		{
			if (HandleLongExit(candle))
				return;
		}
		else if (_currentDirection == -1)
		{
			if (HandleShortExit(candle))
				return;
		}
		else
		{
			// Reset counters when all positions are closed.
			ResetLongState();
			ResetShortState();
		}

		if (ShouldEnterLong())
		{
			EnterLong(candle);
		}
		else if (ShouldEnterShort())
		{
			EnterShort(candle);
		}
	}

	private void EnterLong(ICandleMessage candle)
	{
		var openPositions = _currentDirection == 1 ? _longCount : 0;
		var qty = CalculateOrderVolume(openPositions);
		if (qty <= 0m)
			return;

		if (_currentDirection == -1)
		{
			// Close the short series before switching sides.
			if (Position > 0) SellMarket(Math.Abs(Position)); else if (Position < 0) BuyMarket(Math.Abs(Position));
			ResetShortState();
			_currentDirection = 0;
			openPositions = 0;
		}

		BuyMarket(qty);

		openPositions = Math.Max(0, openPositions) + 1;
		_longCount = openPositions;
		_currentDirection = 1;

		var average = _longCount == 1
		? candle.ClosePrice
		: (_longAveragePrice * (_longCount - 1) + candle.ClosePrice) / _longCount;
		_longAveragePrice = average;

		if (StopLossPips > 0m && _adjustedPoint > 0m)
			_longStop = average - StopLossPips * _adjustedPoint;
		else
			_longStop = null;

		if (TakeProfitPips > 0m && _adjustedPoint > 0m)
			_longTake = average + TakeProfitPips * _adjustedPoint;
		else
			_longTake = null;

		_lastDirection = 1;
	}

	private void EnterShort(ICandleMessage candle)
	{
		var openPositions = _currentDirection == -1 ? _shortCount : 0;
		var qty = CalculateOrderVolume(openPositions);
		if (qty <= 0m)
			return;

		if (_currentDirection == 1)
		{
			// Close the long series before switching sides.
			if (Position > 0) SellMarket(Math.Abs(Position)); else if (Position < 0) BuyMarket(Math.Abs(Position));
			ResetLongState();
			_currentDirection = 0;
			openPositions = 0;
		}

		SellMarket(qty);

		openPositions = Math.Max(0, openPositions) + 1;
		_shortCount = openPositions;
		_currentDirection = -1;

		var average = _shortCount == 1
		? candle.ClosePrice
		: (_shortAveragePrice * (_shortCount - 1) + candle.ClosePrice) / _shortCount;
		_shortAveragePrice = average;

		if (StopLossPips > 0m && _adjustedPoint > 0m)
			_shortStop = average + StopLossPips * _adjustedPoint;
		else
			_shortStop = null;

		if (TakeProfitPips > 0m && _adjustedPoint > 0m)
			_shortTake = average - TakeProfitPips * _adjustedPoint;
		else
			_shortTake = null;

		_lastDirection = -1;
	}

	private bool HandleLongExit(ICandleMessage candle)
	{
		var exitTriggered = false;

		if (_longTake.HasValue && candle.HighPrice >= _longTake.Value)
		{
			// Take-profit reached for the long series.
			SellMarket(Math.Abs(Position));
			exitTriggered = true;
		}
		else if (_longStop.HasValue && candle.LowPrice <= _longStop.Value)
		{
			// Stop-loss touched for the long series.
			SellMarket(Math.Abs(Position));
			exitTriggered = true;
		}
		else if (TrailingStopPips > 0m && StopLossPips > 0m && _longCount > 0 && _adjustedPoint > 0m)
		{
			var trailingDistance = TrailingStopPips * _adjustedPoint;
			var profit = candle.ClosePrice - _longAveragePrice;
			if (trailingDistance > 0m && profit > trailingDistance)
			{
				var newStop = candle.ClosePrice - trailingDistance;
				if (!_longStop.HasValue || _longStop.Value < newStop)
					_longStop = newStop;
			}
		}

		if (exitTriggered)
		{
			ResetLongState();
			_currentDirection = 0;
			return true;
		}

		return false;
	}

	private bool HandleShortExit(ICandleMessage candle)
	{
		var exitTriggered = false;

		if (_shortTake.HasValue && candle.LowPrice <= _shortTake.Value)
		{
			// Take-profit reached for the short series.
			BuyMarket(Math.Abs(Position));
			exitTriggered = true;
		}
		else if (_shortStop.HasValue && candle.HighPrice >= _shortStop.Value)
		{
			// Stop-loss touched for the short series.
			BuyMarket(Math.Abs(Position));
			exitTriggered = true;
		}
		else if (TrailingStopPips > 0m && StopLossPips > 0m && _shortCount > 0 && _adjustedPoint > 0m)
		{
			var trailingDistance = TrailingStopPips * _adjustedPoint;
			var profit = _shortAveragePrice - candle.ClosePrice;
			if (trailingDistance > 0m && profit > trailingDistance)
			{
				var newStop = candle.ClosePrice + trailingDistance;
				if (!_shortStop.HasValue || _shortStop.Value > newStop)
					_shortStop = newStop;
			}
		}

		if (exitTriggered)
		{
			ResetShortState();
			_currentDirection = 0;
			return true;
		}

		return false;
	}

	private bool ShouldEnterLong()
	{
		var openPositions = _currentDirection == 1 ? _longCount : 0;
		if (MaxTrades <= 0)
			return false;

		var firstEntry = _lastDirection == -1 && openPositions == 0;
		var addEntry = _lastDirection == 1 && openPositions > 0 && openPositions < MaxTrades;
		return firstEntry || addEntry;
	}

	private bool ShouldEnterShort()
	{
		var openPositions = _currentDirection == -1 ? _shortCount : 0;
		if (MaxTrades <= 0)
			return false;

		var firstEntry = _lastDirection == 1 && openPositions == 0;
		var addEntry = _lastDirection == -1 && openPositions > 0 && openPositions < MaxTrades;
		return firstEntry || addEntry;
	}

	private decimal CalculateOrderVolume(int openPositions)
	{
		var defaultVolume = Volume > 0m ? Volume : 1m;
		var minVolume = Security?.MinVolume ?? defaultVolume;
		var volumeStep = Security?.VolumeStep ?? 0m;
		var maxVolume = Security?.MaxVolume;

		if (minVolume <= 0m)
			minVolume = defaultVolume;

		if (MaxTrades <= 0 || MaxRisk <= 0m)
			return minVolume;

		var denominatorBase = (decimal)MaxTrades / MaxRisk;
		var denominator = denominatorBase - openPositions;
		if (denominator <= 0m)
			return 0m;

		var fraction = 1m / denominator;
		var equity = Portfolio?.CurrentValue ?? 0m;
		if (equity <= 0m)
			return 0m;

		var pip = _adjustedPoint;
		if (pip <= 0m)
		{
			var priceStep = Security?.PriceStep ?? 0m;
			pip = priceStep > 0m ? priceStep : 1m;
		}

		var stopLoss = StopLossPips > 0m ? StopLossPips : 1m;
		var riskPerUnit = stopLoss * pip;
		if (riskPerUnit <= 0m)
			return minVolume;

		var qty = equity * fraction / riskPerUnit;

		if (volumeStep > 0m)
			qty = Math.Floor(qty / volumeStep) * volumeStep;

		if (qty < minVolume)
			qty = minVolume;

		if (maxVolume.HasValue && maxVolume.Value > 0m && qty > maxVolume.Value)
			qty = maxVolume.Value;

		return qty;
	}

	private void UpdateExtremeLevels(ICandleMessage candle)
	{
		if (_lastDirection != 0)
			return;

		var trailingDistance = TrailingStopPips * _adjustedPoint;
		if (trailingDistance <= 0m)
			return;

		if (candle.HighPrice > _bidMax)
			_bidMax = candle.HighPrice;

		if (candle.LowPrice < _askMin)
			_askMin = candle.LowPrice;

		if (_bidMax != decimal.MinValue && candle.LowPrice < _bidMax - trailingDistance)
		{
			_lastDirection = -1;
			return;
		}

		if (_askMin != decimal.MaxValue && candle.HighPrice > _askMin + trailingDistance)
			_lastDirection = 1;
	}

	private decimal GetAdjustedPoint()
	{
		var step = Security?.PriceStep ?? 0m;
		if (step <= 0m)
			return 1m;

		var decimals = CountDecimals(step);
		if (decimals == 3 || decimals == 5)
			return step * 10m;

		return step;
	}

	private static int CountDecimals(decimal value)
	{
		value = Math.Abs(value);
		var text = value.ToString(CultureInfo.InvariantCulture);
		var index = text.IndexOf('.');
		return index < 0 ? 0 : text.Length - index - 1;
	}

	private void ResetState()
	{
		_bidMax = decimal.MinValue;
		_askMin = decimal.MaxValue;
		_lastDirection = 0;
		_currentDirection = 0;
		ResetLongState();
		ResetShortState();
		_adjustedPoint = 0m;
	}

	private void ResetLongState()
	{
		_longCount = 0;
		_longAveragePrice = 0m;
		_longStop = null;
		_longTake = null;
	}

	private void ResetShortState()
	{
		_shortCount = 0;
		_shortAveragePrice = 0m;
		_shortStop = null;
		_shortTake = null;
	}
}