GitHub で見る

Two Per Bar Strategy

Overview

The original MetaTrader expert "Two PerBar" opens a long and a short position at the very beginning of every new bar, closes the entire basket on the next bar, and optionally applies a martingale-like volume multiplier. The StockSharp port keeps the same rhythm by tracking both hedged legs explicitly and by reacting once per finished candle. All orders are created through the high-level API and respect the instrument metadata (price step, volume step, and min/max lot constraints).

Trading cycle

  1. New candle detection – the strategy subscribes to the configured candle series via SubscribeCandles. When the candle arrives with State == CandleStates.Finished, a fresh bar has started and the cycle runs.
  2. Evaluate take-profit hits – each stored leg carries its own entry price and take-profit level. If the completed candle’s high or low touches that level, the leg is closed immediately with a market order and removed from the tracking list.
  3. Forced liquidation of leftovers – any legs that survived the take-profit scan are liquidated at market before the next pair is opened. This mirrors the MetaTrader code that calls PositionClose on every bar open.
  4. Determine the next lot size
    • When a previous cycle still had open legs, the largest volume among them is multiplied by VolumeMultiplier.
    • When the basket finished flat (for example, both legs hit their take-profit), the cycle resets to InitialVolume.
    • PrepareVolume normalizes the candidate lot by rounding to two decimals, snapping it to the instrument VolumeStep, verifying against the exchange MinVolume, and finally resetting to InitialVolume if it exceeds either the user defined MaxVolume or the security’s MaxVolume.
  5. Update defaults – the computed lot is stored inside _lastCycleVolume and written into Strategy.Volume so helper methods reuse the same amount.
  6. Spawn a fresh hedged pairBuyMarket(volume) opens the long leg and SellMarket(volume) opens the short leg. Each leg remembers the close price of the finished candle and the absolute take-profit level (entry ± TakeProfitPoints * pointSize). A zero or negative TakeProfitPoints disables the take-profit and only the forced liquidation step will exit the basket.

The result is a perpetual straddle: every candle begins with a long + short pair, both are inspected for profit targets during the bar, and everything is flat before the next cycle.

Money management and protection

  • Martingale-like scalingVolumeMultiplier replicates the MetaTrader multiplier. When any leg survives until the forced liquidation step, the next cycle uses the heaviest leg’s size multiplied by this value. A completed profitable cycle (both legs closed via take-profit) resets the lot back to InitialVolume.
  • Volume cappingMaxVolume is a hard cap that forces the lot back to InitialVolume once the multiplier would exceed it. The same reset happens if the instrument reports a tighter Security.MaxVolume.
  • Exchange compliance – all volumes are snapped to the security VolumeStep and rejected when they fall below MinVolume. Setting InitialVolume to a tradable size guarantees that the reset path always remains valid.
  • Point calculation – the take-profit offset uses Security.PriceStep (or MinPriceStep as a fallback). Instruments without a defined step effectively disable the take-profit because the computed offset is zero.

Parameters

Name Type Default Description
CandleType DataType 1-minute time frame Primary timeframe that triggers the once-per-bar workflow.
InitialVolume decimal 1 Lot size used when starting a new cycle with no surviving legs.
VolumeMultiplier decimal 2 Multiplier applied to the largest surviving leg from the previous cycle.
MaxVolume decimal 10 Maximum permitted lot size before resetting to InitialVolume.
TakeProfitPoints int 50 Distance in price points used to build the per-leg take-profit target. 0 disables take-profit and relies solely on the bar-close liquidation.

Implementation notes and differences

  • Hedged legs are tracked manually inside _legs so that the strategy can reason about individual long/short exposures even though StockSharp reports only the net position.
  • Instead of relying on individual ticks, the take-profit logic checks the completed candle’s high/low range. This keeps the implementation deterministic while staying faithful to the original "per bar" behavior.
  • The MetaTrader slippage and magic number settings are not exposed; StockSharp handles order routing details, and the strategy runs on the portfolio associated with the parent strategy instance.
  • Order placement uses the Strategy helper methods (BuyMarket, SellMarket) without adding indicators directly to Strategy.Indicators, complying with the repository guidelines.

Usage tips

  • Match InitialVolume to the instrument’s lot step before starting the strategy. The constructor does not attempt to automatically round your input.
  • If the instrument has a very small price step, consider reducing TakeProfitPoints; otherwise the calculated take-profit may sit unrealistically far away.
  • Because the strategy opens opposite-direction orders at the same time, run it on connectors/exchanges that allow hedged positions. In environments that net positions immediately, the _legs list still reflects the intended logic, but actual broker behavior may differ.
  • Add the strategy to a chart to visualize candles and executed trades (DrawCandles + DrawOwnTrades are enabled in 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;
	}
}