Открыть на GitHub

Стратегия Two Per Bar

Обзор

Советник MetaTrader "Two PerBar" открывает длинную и короткую позицию в самом начале каждой новой свечи, закрывает весь пакет на следующей свече и при необходимости умножает объём по принципу мартингейла. Порт на StockSharp воспроизводит тот же ритм: обе разнонаправленные ноги учитываются явно, а обработка выполняется один раз на закрытии свечи. Все заявки отправляются через высокоуровневые методы Strategy и учитывают биржевые ограничения инструмента (шаг цены, шаг объёма, минимальные и максимальные лоты).

Торговый цикл

  1. Фиксация новой свечи. Подписка создаётся через SubscribeCandles. Как только приходит свеча со статусом CandleStates.Finished, начинается очередной цикл.
  2. Проверка тейк-профитов. Каждая нога хранит цену входа и целевой уровень. Если максимум или минимум завершившейся свечи достигает уровня, нога немедленно закрывается рыночной заявкой и удаляется из списка.
  3. Принудительное закрытие остатка. Все ноги, которые выжили после шага тейк-профита, ликвидируются по рынку до открытия нового пакета. Это повторяет вызов PositionClose в оригинале.
  4. Расчёт следующего объёма:
    • Если в прошлом цикле остались незакрытые ноги, берётся максимальный объём среди них и умножается на VolumeMultiplier.
    • Если обе ноги закрылись (например, по тейк-профиту), стратегия возвращается к InitialVolume.
    • Метод PrepareVolume округляет кандидат до двух знаков, совмещает со VolumeStep, проверяет на MinVolume и сбрасывает на InitialVolume, если ограничение MaxVolume или Security.MaxVolume превышено.
  5. Обновление значений по умолчанию. Рассчитанный объём сохраняется в _lastCycleVolume и записывается в Strategy.Volume, чтобы вспомогательные методы использовали ту же величину.
  6. Открытие новой пары. BuyMarket(volume) создаёт длинную ногу, SellMarket(volume) — короткую. Для каждой ноги фиксируется цена закрывшейся свечи и абсолютный уровень тейк-профита (entry ± TakeProfitPoints * pointSize). Если TakeProfitPoints <= 0, тейк-профит отключается и закрытие происходит только на следующем баре.

Таким образом формируется непрерывный "страддл": в начале каждой свечи открывается пара позиций, в течение бара они отслеживаются на предмет тейк-профита, а к старту следующей свечи позиция всегда обнуляется.

Управление объёмом и защита

  • Мартингейл. Параметр VolumeMultiplier повторяет множитель из MetaTrader. Если какая-либо нога дожила до принудительного закрытия, следующий цикл использует объём крупнейшей ноги, умноженный на этот коэффициент. Профитный цикл (обе ноги закрыты по цели) возвращает объём к InitialVolume.
  • Ограничение объёма. MaxVolume — жёсткий предел: как только расчёт превысил его (или Security.MaxVolume), объём сбрасывается к базовому значению.
  • Соответствие бирже. Все объёмы подгоняются под VolumeStep и отклоняются, если меньше MinVolume. Убедитесь, что InitialVolume укладывается в требования площадки.
  • Расчёт шага цены. Смещение тейк-профита умножает TakeProfitPoints на Security.PriceStep (или MinPriceStep, если основной шаг не задан). При нулевом шаге тейк-профит фактически отключается.

Параметры

Название Тип Значение по умолчанию Описание
CandleType DataType Таймфрейм 1 минута Основная свечная серия, задающая частоту входов.
InitialVolume decimal 1 Объём первой пары, если прошлый цикл завершился без открытых ног.
VolumeMultiplier decimal 2 Множитель для максимальной ноги предыдущего цикла.
MaxVolume decimal 10 Верхний предел объёма перед сбросом на InitialVolume.
TakeProfitPoints int 50 Дистанция в пунктах для расчёта тейк-профита каждой ноги. Значение 0 отключает тейк-профит и оставляет только принудительное закрытие на следующем баре.

Особенности реализации и отличия от MQL5

  • Ноги портфеля хранятся во внутреннем списке _legs, чтобы стратегия могла различать длинную и короткую часть, даже если коннектор поддерживает только неттинг.
  • Тейк-профит определяется по диапазону завершившейся свечи (High/Low), что соответствует "побарному" подходу и делает поведение детерминированным.
  • Параметры slippage и magic number из MetaTrader не используются: маршрутизация заявок полностью возлагается на StockSharp.
  • Заявки отправляются через BuyMarket и SellMarket без добавления индикаторов в Strategy.Indicators, полностью следуя рекомендациям репозитория.

Практические рекомендации

  • Подбирайте InitialVolume согласно шагу объёма инструмента — стратегия не нормализует параметр автоматически.
  • Для инструментов с маленьким шагом цены уменьшите TakeProfitPoints, иначе цель может оказаться слишком далёкой.
  • Стратегия одновременно открывает встречные позиции. Используйте коннекторы и счета, где разрешено хеджирование. В неттинговой среде логика _legs сохраняется, но фактическое исполнение брокера может отличаться.
  • Добавьте стратегию на график, чтобы видеть свечи и совершённые сделки (DrawCandles и DrawOwnTrades включены в OnStarted).
namespace StockSharp.Samples.Strategies;

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;

/// <summary>
/// Strategy that opens a hedged pair of market orders on every new bar.
/// </summary>
public class TwoPerBarStrategy : Strategy
{
	private sealed class HedgeLeg
	{
		public bool IsLong;
		public decimal Volume;
		public decimal EntryPrice;
		public decimal? TakeProfitPrice;
	}

	private readonly List<HedgeLeg> _legs = new();

	private readonly StrategyParam<DataType> _candleType;
	private readonly StrategyParam<decimal> _initialVolume;
	private readonly StrategyParam<decimal> _volumeMultiplier;
	private readonly StrategyParam<decimal> _maxVolume;
	private readonly StrategyParam<int> _takeProfitPoints;

	private decimal _pointSize;
	private decimal _lastCycleVolume;

	/// <summary>
	/// Initializes a new instance of <see cref="TwoPerBarStrategy"/>.
	/// </summary>
	public TwoPerBarStrategy()
	{
		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(1).TimeFrame())
			.SetDisplay("Candle Type", "Primary timeframe used to detect new bars.", "General");

		_initialVolume = Param(nameof(InitialVolume), 1m)
			.SetGreaterThanZero()
			.SetDisplay("Initial Volume", "Lot size used when no previous positions exist.", "Trading")
			;

		_volumeMultiplier = Param(nameof(VolumeMultiplier), 2m)
			.SetGreaterThanZero()
			.SetDisplay("Volume Multiplier", "Factor applied to the heaviest remaining leg after closing a cycle.", "Trading")
			;

		_maxVolume = Param(nameof(MaxVolume), 10m)
			.SetGreaterThanZero()
			.SetDisplay("Maximum Volume", "Upper limit for the calculated lot size before resetting to the initial value.", "Risk")
			;

		_takeProfitPoints = Param(nameof(TakeProfitPoints), 50)
			.SetNotNegative()
			.SetDisplay("Take Profit (points)", "Distance to the take profit expressed in instrument points.", "Risk")
			;
	}

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

	/// <summary>
	/// Base lot size for a fresh cycle.
	/// </summary>
	public decimal InitialVolume
	{
		get => _initialVolume.Value;
		set => _initialVolume.Value = value;
	}

	/// <summary>
	/// Multiplier applied to the previous maximum lot size.
	/// </summary>
	public decimal VolumeMultiplier
	{
		get => _volumeMultiplier.Value;
		set => _volumeMultiplier.Value = value;
	}

	/// <summary>
	/// Hard limit for the calculated lot size.
	/// </summary>
	public decimal MaxVolume
	{
		get => _maxVolume.Value;
		set => _maxVolume.Value = value;
	}

	/// <summary>
	/// Take profit distance in price points.
	/// </summary>
	public int TakeProfitPoints
	{
		get => _takeProfitPoints.Value;
		set => _takeProfitPoints.Value = value;
	}

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

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

		_legs.Clear();
		_lastCycleVolume = 0m;
	}

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

		_pointSize = CalculatePointSize();
		_lastCycleVolume = PrepareVolume(InitialVolume);

		if (_lastCycleVolume > 0m)
		Volume = _lastCycleVolume;

		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;

		CheckTakeProfitHits(candle);

		var hadLegs = _legs.Count > 0;
		var maxVolume = 0m;

		for (var i = 0; i < _legs.Count; i++)
		{
		var leg = _legs[i];

		if (leg.Volume > maxVolume)
		maxVolume = leg.Volume;
		}

		if (_legs.Count > 0)
		CloseAllLegs();

		var nextVolume = hadLegs ? maxVolume * VolumeMultiplier : InitialVolume;
		nextVolume = PrepareVolume(nextVolume);

		if (nextVolume <= 0m)
		return;

		_lastCycleVolume = nextVolume;
		Volume = nextVolume;

		if (!IsFormedAndOnlineAndAllowTrading())
		return;

		var offset = TakeProfitPoints > 0 && _pointSize > 0m ? TakeProfitPoints * _pointSize : 0m;
		OpenHedgePair(candle.ClosePrice, offset);
	}

	private void CheckTakeProfitHits(ICandleMessage candle)
	{
		if (TakeProfitPoints <= 0)
		return;

		for (var i = _legs.Count - 1; i >= 0; i--)
		{
		var leg = _legs[i];
		var target = leg.TakeProfitPrice;

		if (target is null)
		continue;

		if (leg.IsLong)
		{
		if (candle.HighPrice >= target.Value)
		{
		SellMarket(leg.Volume);
		_legs.RemoveAt(i);
		}
		}
		else
		{
		if (candle.LowPrice <= target.Value)
		{
		BuyMarket(leg.Volume);
		_legs.RemoveAt(i);
		}
		}
		}
	}

	private void CloseAllLegs()
	{
		for (var i = _legs.Count - 1; i >= 0; i--)
		{
		var leg = _legs[i];

		if (leg.IsLong)
		SellMarket(leg.Volume);
		else
		BuyMarket(leg.Volume);
		}

		_legs.Clear();
	}

	private void OpenHedgePair(decimal entryPrice, decimal takeProfitOffset)
	{
		var volume = _lastCycleVolume;
		if (volume <= 0m)
		return;

		var longOrder = BuyMarket(volume);
		if (longOrder is not null)
		{
		_legs.Add(new HedgeLeg
		{
		IsLong = true,
		Volume = volume,
		EntryPrice = entryPrice,
		TakeProfitPrice = takeProfitOffset > 0m ? entryPrice + takeProfitOffset : null
		});
		}

		var shortOrder = SellMarket(volume);
		if (shortOrder is not null)
		{
		_legs.Add(new HedgeLeg
		{
		IsLong = false,
		Volume = volume,
		EntryPrice = entryPrice,
		TakeProfitPrice = takeProfitOffset > 0m ? entryPrice - takeProfitOffset : null
		});
		}
	}

	private decimal PrepareVolume(decimal candidate)
	{
		if (candidate <= 0m)
		return 0m;

		if (ShouldResetVolume(candidate))
		candidate = InitialVolume;

		var normalized = NormalizeVolume(candidate);

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

		if (ShouldResetVolume(normalized))
		normalized = NormalizeVolume(InitialVolume);

		return normalized;
	}

	private bool ShouldResetVolume(decimal volume)
	{
		if (volume <= 0m)
		return false;

		if (MaxVolume > 0m && volume > MaxVolume)
		return true;

		var security = Security;
		var maxFromSecurity = security?.MaxVolume;

		return maxFromSecurity != null && volume > maxFromSecurity.Value;
	}

	private decimal NormalizeVolume(decimal volume)
	{
		if (volume <= 0m)
		return 0m;

		var normalized = decimal.Round(volume, 2, MidpointRounding.ToZero);

		var security = Security;
		if (security != null)
		{
		var step = security.VolumeStep ?? 0m;
		if (step > 0m)
		normalized = step * Math.Floor(normalized / step);

		var min = security.MinVolume ?? 0m;
		if (min > 0m && normalized < min)
		return 0m;

		var max = security.MaxVolume;
		if (max != null && normalized > max.Value)
		normalized = max.Value;
		}

		return normalized > 0m ? normalized : 0m;
	}

	private decimal CalculatePointSize()
	{
		var security = Security;
		if (security?.PriceStep is decimal step && step > 0m)
		return step;

		return 0m;
	}
}