Auf GitHub ansehen

Two Per Bar Strategie

Überblick

Der ursprüngliche MetaTrader-Experte "Two PerBar" eröffnet zu Beginn jedes neuen Balkens eine Long- und eine Short-Position, schließt den gesamten Korb auf dem nächsten Balken und wendet optional einen martingalähnlichen Volumenmultiplikator an. Der StockSharp-Port hält denselben Rhythmus, indem er beide gehedgten Beine explizit verfolgt und einmal pro abgeschlossener Kerze reagiert. Alle Orders werden über die High-Level-API erstellt und respektieren die Instrument-Metadaten (Preisschritt, Volumenschritt und Min/Max-Lot-Beschränkungen).

Handelszyklus

  1. Neue Kerze erkennen – die Strategie abonniert die konfigurierte Kerzenserie über SubscribeCandles. Wenn die Kerze mit State == CandleStates.Finished eintrifft, hat ein neuer Balken begonnen und der Zyklus läuft.
  2. Take-Profit-Treffer auswerten – jedes gespeicherte Bein trägt seinen eigenen Einstiegspreis und Take-Profit-Level. Wenn das Hoch oder Tief der abgeschlossenen Kerze diesen Level berührt, wird das Bein sofort mit einer Marktorder geschlossen und aus der Verfolgungsliste entfernt.
  3. Zwangsliquidation von Überbleibseln – alle Beine, die den Take-Profit-Scan überlebt haben, werden vor dem Öffnen des nächsten Paares zum Markt liquidiert. Dies spiegelt den MetaTrader-Code wider, der PositionClose bei jedem Balkenöffnen aufruft.
  4. Nächste Losgröße bestimmen
    • Wenn ein vorheriger Zyklus noch offene Beine hatte, wird das größte Volumen unter ihnen mit VolumeMultiplier multipliziert.
    • Wenn der Korb flach endete (zum Beispiel, beide Beine haben ihren Take-Profit getroffen), wird der Zyklus auf InitialVolume zurückgesetzt.
    • PrepareVolume normalisiert das Kandidaten-Lot, indem es auf zwei Dezimalstellen rundet, es an den Instrument-VolumeStep anpasst, gegen das Börsen-MinVolume prüft, und schließlich auf InitialVolume zurücksetzt, wenn es entweder das benutzerdefinierte MaxVolume oder das Security.MaxVolume übersteigt.
  5. Standardwerte aktualisieren – das berechnete Lot wird in _lastCycleVolume gespeichert und in Strategy.Volume geschrieben, damit Hilfsmethoden denselben Betrag wiederverwenden.
  6. Ein neues gehedgtes Paar öffnenBuyMarket(volume) eröffnet das Long-Bein und SellMarket(volume) das Short-Bein. Jedes Bein merkt sich den Schlusskurs der abgeschlossenen Kerze und den absoluten Take-Profit-Level (entry ± TakeProfitPoints * pointSize). Ein null oder negativer TakeProfitPoints deaktiviert den Take-Profit und nur der Zwangsliquidationsschritt schließt den Korb.

Das Ergebnis ist ein perpetueller Straddle: jede Kerze beginnt mit einem Long+Short-Paar, beide werden während des Balkens auf Gewinnziele überprüft, und alles ist flach vor dem nächsten Zyklus.

Geldmanagement und Schutz

  • Martingalähnliche SkalierungVolumeMultiplier repliziert den MetaTrader-Multiplikator. Wenn ein Bein bis zum Zwangsliquidationsschritt überlebt, verwendet der nächste Zyklus die Größe des schwersten Beins multipliziert mit diesem Wert. Ein abgeschlossener profitabler Zyklus (beide Beine über Take-Profit geschlossen) setzt das Lot auf InitialVolume zurück.
  • VolumendeckelungMaxVolume ist eine harte Obergrenze, die das Lot auf InitialVolume zurückzwingt, sobald der Multiplikator es überschreiten würde. Dasselbe Reset passiert, wenn das Instrument ein engeres Security.MaxVolume meldet.
  • Börsenkonformität – alle Volumina werden an das Security.VolumeStep angepasst und abgelehnt, wenn sie unter MinVolume fallen. Das Setzen von InitialVolume auf eine handelbare Größe garantiert, dass der Reset-Pfad immer gültig bleibt.
  • Punktberechnung – der Take-Profit-Offset verwendet Security.PriceStep (oder MinPriceStep als Fallback). Instrumente ohne definierten Schritt deaktivieren den Take-Profit effektiv, da der berechnete Offset null ist.

Parameter

Name Typ Standard Beschreibung
CandleType DataType 1-Minuten-Zeitrahmen Primärer Zeitrahmen, der den einmal-pro-Balken-Workflow auslöst.
InitialVolume decimal 1 Losgröße beim Start eines neuen Zyklus ohne überlebende Beine.
VolumeMultiplier decimal 2 Multiplikator auf das größte überlebende Bein aus dem vorherigen Zyklus.
MaxVolume decimal 10 Maximale erlaubte Losgröße vor dem Zurücksetzen auf InitialVolume.
TakeProfitPoints int 50 Abstand in Preispunkten für das Take-Profit-Ziel pro Bein. 0 deaktiviert den Take-Profit und verlässt sich ausschließlich auf die Balkenschluss-Liquidation.

Implementierungshinweise und Unterschiede

  • Gehedgte Beine werden manuell in _legs verfolgt, damit die Strategie über individuelle Long/Short-Exposures nachdenken kann, obwohl StockSharp nur die Nettoposition meldet.
  • Anstatt sich auf einzelne Ticks zu verlassen, prüft die Take-Profit-Logik den Hoch/Tief-Bereich der abgeschlossenen Kerze. Dies hält die Implementierung deterministisch, während sie dem ursprünglichen "pro Balken"-Verhalten treu bleibt.
  • Die MetaTrader-Slippage- und Magic-Number-Einstellungen sind nicht exponiert; StockSharp verarbeitet die Order-Routing-Details, und die Strategie läuft im Portfolio der übergeordneten Strategieinstanz.
  • Die Orderplatzierung verwendet die Strategy-Hilfsmethoden (BuyMarket, SellMarket) ohne Indikatoren direkt zu Strategy.Indicators hinzuzufügen, gemäß den Repository-Richtlinien.

Verwendungstipps

  • Passen Sie InitialVolume an den Lot-Schritt des Instruments an, bevor Sie die Strategie starten. Der Konstruktor versucht nicht, Ihre Eingabe automatisch zu runden.
  • Wenn das Instrument einen sehr kleinen Preisschritt hat, erwägen Sie, TakeProfitPoints zu reduzieren; andernfalls kann der berechnete Take-Profit unrealistisch weit entfernt liegen.
  • Da die Strategie gleichzeitig Aufträge in entgegengesetzte Richtungen öffnet, führen Sie sie auf Konnektoren/Börsen aus, die gehedgte Positionen erlauben. In Umgebungen, die Positionen sofort netten, spiegelt die _legs-Liste noch die beabsichtigte Logik wider, aber das tatsächliche Broker-Verhalten kann abweichen.
  • Fügen Sie die Strategie einem Chart hinzu, um Kerzen und ausgeführte Trades zu visualisieren (DrawCandles + DrawOwnTrades sind in OnStarted aktiviert).
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;
	}
}