Auf GitHub ansehen

Spreader 2-Strategie

Übersicht

Die Spreader 2-Strategie ist ein Pair-Trading-System, das aus dem MetaTrader Expert Advisor "Spreader 2" konvertiert wurde. Sie beobachtet zwei korrelierte Instrumente auf einem Ein-Minuten-Zeitrahmen und sucht nach kurzfristigen Abweichungen zwischen ihren Preisbewegungen. Wenn beide Beine innerhalb kontrollierter Volatilitätsgrenzen divergieren und dabei positive Korrelation aufrechterhalten, eröffnet die Strategie einen marktneutralen Spread durch eine Long-Position in einem Symbol und eine Short-Position im anderen. Die kombinierte Position wird geschlossen, wenn der gesamte schwebende Gewinn das konfigurierte Ziel erreicht oder wenn Korrelationsregeln verletzt werden.

Kernlogik

  1. Fertige Kerzen für das primäre und sekundäre Symbol empfangen und nach Schließzeit ausrichten.
  2. Rollierende Listen von Schlusspreisen pflegen, damit der Algorithmus auf Werte verweisen kann, die ShiftLength, 2 * ShiftLength und 1440 Balken in der Vergangenheit liegen.
  3. Erste Differenzen berechnen (x1, x2 für das primäre Symbol und y1, y2 für das sekundäre Symbol), um lokale Schwankungen zu erkennen.
  4. Handel überspringen, wenn ein Instrument zwei aufeinanderfolgende Bewegungen in die gleiche Richtung zeigt (Trendfilter) oder wenn die Produkte x1 * y1 negative Korrelation anzeigen.
  5. Das Volatilitätsverhältnis a / b auswerten, wobei a = |x1| + |x2| und b = |y1| + |y2|. Nur fortfahren, wenn das Verhältnis zwischen 0.3 und 3.0 bleibt.
  6. Das sekundäre Beinvolumen proportional zum Volatilitätsverhältnis skalieren und an Volumen-Schritt, Minimum und Maximum des Kontrakts anpassen.
  7. Die beabsichtigte Handelsrichtung mit dem 1440-Balken-Rückblick (ungefähr ein Handelstag) bestätigen. Der Spread wird nur eröffnet, wenn die Tagesbewegung das kurzfristige Signal unterstützt.
  8. Die Strategie eröffnet beide Beine gleichzeitig: das primäre Symbol handelt mit dem konfigurierten PrimaryVolume, während das sekundäre Symbol die angepasste Größe in entgegengesetzter Richtung handelt.
  9. Während Positionen offen sind, verfolgt das System kontinuierlich den schwebenden Gewinn beider Beine. Wenn der kombinierte Gewinn TargetProfit übersteigt, schließt es den Spread und setzt die Einstiegsreferenzen zurück.
  10. Sicherheitschecks schließen automatisch verwaiste Positionen, wenn ein Bein unerwartet aussteigt, und eröffnen fehlende Beine wenn möglich neu, um die Absicherung ausgewogen zu halten.

Parameter

  • SecondSecurity – sekundäres Instrument, das am Spread teilnimmt. Dieser Parameter ist erforderlich.
  • PrimaryVolume – Handelsvolumen (in Lots/Kontrakten) für das primäre Symbol. Standard ist 1.
  • TargetProfit – absolutes monetäres Gewinnziel für das kombinierte Paar. Standard ist 100.
  • ShiftLength – Anzahl der Kerzen zwischen Vergleichspunkten, die in Erste-Differenz-Berechnungen verwendet werden. Standard ist 30.
  • CandleType – Datentyp für Kerzen-Subskriptionen. Standardmäßig arbeitet die Strategie mit Ein-Minuten-Zeitrahmen-Kerzen.

Handelsregeln

  • Nur fertige Kerzen werden verarbeitet, um Aktionen auf unvollständigen Daten zu vermeiden.
  • Trendfilter müssen für beide Symbole über die letzten zwei ShiftLength-Fenster entgegengesetzte Bewegungen zeigen.
  • Die Korrelation muss positiv sein, und das Volatilitätsverhältnis muss im Band [0.3, 3.0] bleiben.
  • Die Bestätigungsprüfung gegen den 1440-Balken-Rückblick verhindert Trades, die der längerfristigen Richtung widersprechen.
  • Aufträge werden mit OrderTypes.Market gesendet. Das sekundäre Bein wird explizit mit dem sekundären Wertpapier und Portfolio registriert, um das MetaTrader-Verhalten widerzuspiegeln.
  • Der offene Gewinn wird anhand der letzten Kerzenschlüsse und gespeicherten Einstiegspreise berechnet, um zu bestimmen, wann der Spread zu schließen ist.

Hinweise

  • Die Strategie setzt voraus, dass beide Instrumente kompatible Kontraktspezifikationen teilen. Wenn Multiplikatoren abweichen, wird der Handel deaktiviert und eine Warnung protokolliert.
  • Da der ursprüngliche Algorithmus auf einem vollständigen Tag historischer Daten basiert, wartet auch die StockSharp-Version, bis mindestens 1440 Kerzen für den ersten Einstieg angesammelt sind.
  • Alle Risikomanagement-Logik (Gewinnziel, Behandlung verwaister Beine) ist in der Strategie enthalten. Zusätzliche Schutzmaßnahmen wie Stop-Losses können bei Bedarf extern hinzugefügt werden.
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 Ecng.ComponentModel;

namespace StockSharp.Samples.Strategies;

/// <summary>
/// Pair trading strategy inspired by the "Spreader 2" MetaTrader expert.
/// Looks for short term mean-reverting moves between two correlated symbols
/// and trades the spread once correlation and volatility filters align.
/// </summary>
public class Spreader2Strategy : Strategy
{
	private readonly StrategyParam<Security> _secondSecurityParam;
	private readonly StrategyParam<decimal> _primaryVolumeParam;
	private readonly StrategyParam<decimal> _targetProfitParam;
	private readonly StrategyParam<int> _shiftParam;
	private readonly StrategyParam<DataType> _candleTypeParam;
	private readonly StrategyParam<int> _dayBarsParam;

	private readonly Queue<ICandleMessage> _firstPending = new();
	private readonly Queue<ICandleMessage> _secondPending = new();
	private readonly List<decimal> _firstCloses = new();
	private readonly List<decimal> _secondCloses = new();
	private static readonly object _sync = new();

	private decimal _lastFirstClose;
	private decimal _lastSecondClose;

	private decimal _firstEntryPrice;
	private decimal _secondEntryPrice;
	private decimal _secondPosition;

	private Portfolio _secondPortfolio;
	private bool _contractsMatch = true;

	/// <summary>
	/// Secondary security involved in the spread.
	/// </summary>
	public Security SecondSecurity
	{
		get => _secondSecurityParam.Value;
		set => _secondSecurityParam.Value = value;
	}

	/// <summary>
	/// Trading volume for the primary security.
	/// </summary>
	public decimal PrimaryVolume
	{
		get => _primaryVolumeParam.Value;
		set => _primaryVolumeParam.Value = value;
	}

	/// <summary>
	/// Target profit (absolute money) for the combined position.
	/// </summary>
	public decimal TargetProfit
	{
		get => _targetProfitParam.Value;
		set => _targetProfitParam.Value = value;
	}

	/// <summary>
	/// Number of bars between comparison points.
	/// </summary>
	public int ShiftLength
	{
		get => _shiftParam.Value;
		set => _shiftParam.Value = value;
	}

	/// <summary>
	/// Number of intraday bars considered when calculating daily statistics.
	/// </summary>
	public int DayBars
	{
		get => _dayBarsParam.Value;
		set => _dayBarsParam.Value = value;
	}

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

	/// <summary>
	/// Initializes a new instance of the <see cref="Spreader2Strategy"/> class.
	/// </summary>
	public Spreader2Strategy()
	{
		_secondSecurityParam = Param<Security>(nameof(SecondSecurity))
			.SetDisplay("Second Symbol", "Secondary instrument for the spread trade", "General")
			.SetRequired();

		_primaryVolumeParam = Param(nameof(PrimaryVolume), 1m)
			.SetGreaterThanZero()
			.SetDisplay("Primary Volume", "Order volume for the primary symbol", "Trading")
			
			.SetOptimize(0.5m, 3m, 0.5m);

		_targetProfitParam = Param(nameof(TargetProfit), 100m)
			.SetGreaterThanZero()
			.SetDisplay("Target Profit", "Total profit target for the pair position", "Risk")
			
			.SetOptimize(20m, 200m, 20m);

		_shiftParam = Param(nameof(ShiftLength), 6)
			.SetGreaterThanZero()
			.SetDisplay("Shift Length", "Number of bars between comparison points", "Logic")
			
			.SetOptimize(10, 60, 10);

		_candleTypeParam = Param(nameof(CandleType), TimeSpan.FromMinutes(5).TimeFrame())
			.SetDisplay("Candle Type", "Timeframe for pair analysis", "General");

		_dayBarsParam = Param(nameof(DayBars), 288)
			.SetGreaterThanZero()
			.SetDisplay("Day Bars", "Number of intraday bars used for rolling statistics", "Data")
			;
	}

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

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

		_firstPending.Clear();
		_secondPending.Clear();
		_firstCloses.Clear();
		_secondCloses.Clear();

		_lastFirstClose = 0m;
		_lastSecondClose = 0m;

		_firstEntryPrice = 0m;
		_secondEntryPrice = 0m;
		_secondPosition = 0m;

		_secondPortfolio = null;
		_contractsMatch = true;
	}

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

		if (SecondSecurity == null)
			throw new InvalidOperationException("Second security is not specified.");

		_secondPortfolio = Portfolio ?? throw new InvalidOperationException("Portfolio is not specified.");

		if (Security?.Multiplier != null && SecondSecurity?.Multiplier != null && Security.Multiplier != SecondSecurity.Multiplier)
		{
			LogWarning($"Contract size mismatch between {Security?.Code} and {SecondSecurity?.Code}. Trading disabled.");
			_contractsMatch = false;
		}

		var primarySubscription = SubscribeCandles(CandleType);
		primarySubscription
			.Bind(ProcessPrimaryCandle)
			.Start();

		var secondarySubscription = SubscribeCandles(CandleType, security: SecondSecurity);
		secondarySubscription
			.Bind(ProcessSecondaryCandle)
			.Start();

		var area = CreateChartArea();
		if (area != null)
		{
			DrawCandles(area, primarySubscription);
			DrawOwnTrades(area);
		}
	}

	private void ProcessPrimaryCandle(ICandleMessage candle)
	{
		if (candle.State != CandleStates.Finished)
			return;

		_lastFirstClose = candle.ClosePrice;
		lock (_sync)
		{
			_firstPending.Enqueue(candle);
			ProcessPendingCandles();
		}
	}

	private void ProcessSecondaryCandle(ICandleMessage candle)
	{
		if (candle.State != CandleStates.Finished)
			return;

		_lastSecondClose = candle.ClosePrice;
		lock (_sync)
		{
			_secondPending.Enqueue(candle);
			ProcessPendingCandles();
		}
	}

	private void ProcessPendingCandles()
	{
		while (_firstPending.Count > 0 && _secondPending.Count > 0)
		{
			var first = _firstPending.Peek();
			var second = _secondPending.Peek();

			if (first is null)
			{
				_firstPending.Dequeue();
				continue;
			}

			if (second is null)
			{
				_secondPending.Dequeue();
				continue;
			}

			if (first.CloseTime < second.CloseTime)
			{
				_firstPending.Dequeue();
				continue;
			}

			if (second.CloseTime < first.CloseTime)
			{
				_secondPending.Dequeue();
				continue;
			}

			_firstPending.Dequeue();
			_secondPending.Dequeue();

			HandlePairedCandles(first, second);
		}
	}

	private void HandlePairedCandles(ICandleMessage firstCandle, ICandleMessage secondCandle)
	{
		var maxHistory = Math.Max(DayBars, ShiftLength * 2) + 10;
		AppendHistory(_firstCloses, firstCandle.ClosePrice, maxHistory);
		AppendHistory(_secondCloses, secondCandle.ClosePrice, maxHistory);

		if (!UpdateProfitCheck(firstCandle.ClosePrice, secondCandle.ClosePrice))
			return;

		if (!_contractsMatch)
			return;

		if (PrimaryVolume <= 0m)
			return;

		if (_firstCloses.Count <= ShiftLength * 2 || _secondCloses.Count <= ShiftLength * 2)
			return;

		if (_firstCloses.Count <= DayBars || _secondCloses.Count <= DayBars)
			return;

		var currentIndex = _firstCloses.Count - 1;
		var secondIndex = _secondCloses.Count - 1;
		var shift = ShiftLength;
		var shiftIndex = currentIndex - shift;
		var shiftIndex2 = currentIndex - (shift * 2);
		var dayIndex = currentIndex - DayBars;
		var secondShiftIndex = secondIndex - shift;
		var secondShiftIndex2 = secondIndex - (shift * 2);
		var secondDayIndex = secondIndex - DayBars;

		if (shiftIndex < 0 || shiftIndex2 < 0 || dayIndex < 0)
			return;

		if (secondShiftIndex < 0 || secondShiftIndex2 < 0 || secondDayIndex < 0)
			return;

		var closeCur0 = _firstCloses[currentIndex];
		var closeCurShift = _firstCloses[shiftIndex];
		var closeCurShift2 = _firstCloses[shiftIndex2];
		var closeCurDay = _firstCloses[dayIndex];

		var closeSec0 = _secondCloses[secondIndex];
		var closeSecShift = _secondCloses[secondShiftIndex];
		var closeSecShift2 = _secondCloses[secondShiftIndex2];
		var closeSecDay = _secondCloses[secondDayIndex];

		// Use relative (percentage) moves so the ratio comparison works for instruments with different price scales.
		var x1 = closeCurShift == 0m ? 0m : (closeCur0 - closeCurShift) / closeCurShift;
		var x2 = closeCurShift2 == 0m ? 0m : (closeCurShift - closeCurShift2) / closeCurShift2;
		var y1 = closeSecShift == 0m ? 0m : (closeSec0 - closeSecShift) / closeSecShift;
		var y2 = closeSecShift2 == 0m ? 0m : (closeSecShift - closeSecShift2) / closeSecShift2;

		if ((x1 * x2) > 0m)
		{
			LogInfo($"Trend detected on {Security?.Code}, skipping correlation check.");
			return;
		}

		if ((y1 * y2) > 0m)
		{
			LogInfo($"Trend detected on {SecondSecurity?.Code}, skipping correlation check.");
			return;
		}

		if ((x1 * y1) <= 0m)
		{
			LogInfo("Negative correlation detected. Waiting for better alignment.");
			return;
		}

		var a = Math.Abs(x1) + Math.Abs(x2);
		var b = Math.Abs(y1) + Math.Abs(y2);

		if (b == 0m)
			return;

		var ratio = a / b;

		if (ratio > 3m)
			return;

		if (ratio < 0.3m)
			return;

		var secondVolume = AdjustSecondaryVolume(ratio * PrimaryVolume);

		if (secondVolume <= 0m)
		{
			LogInfo("Secondary volume too small after adjustment. Skipping trade.");
			return;
		}

		var x3 = closeCurDay == 0m ? 0m : (closeCur0 - closeCurDay) / closeCurDay;
		var y3 = closeSecDay == 0m ? 0m : (closeSec0 - closeSecDay) / closeSecDay;

		var primarySide = x1 * b > y1 * a ? Sides.Buy : Sides.Sell;
		var secondarySide = primarySide == Sides.Buy ? Sides.Sell : Sides.Buy;

		if (primarySide == Sides.Buy && (x3 * b) < (y3 * a))
		{
			LogInfo("Buy signal rejected by daily confirmation check.");
			return;
		}

		if (primarySide == Sides.Sell && (x3 * b) > (y3 * a))
		{
			LogInfo("Sell signal rejected by daily confirmation check.");
			return;
		}

		OpenPair(primarySide, secondarySide, secondVolume);
	}

	private bool UpdateProfitCheck(decimal firstClose, decimal secondClose)
	{
		var primaryPosition = Position;
		var hasSecondary = _secondPosition != 0m;

		if (primaryPosition == 0m && !hasSecondary)
			return true;

		if (primaryPosition != 0m && !hasSecondary)
		{
			LogInfo("Secondary position missing. Closing primary exposure.");
			ClosePrimaryPosition();
			return false;
		}

		if (primaryPosition == 0m && hasSecondary)
		{
			var requiredSide = _secondPosition > 0m ? Sides.Sell : Sides.Buy;
			LogInfo("Primary position missing. Opening trade to balance spread.");
			OpenPrimary(requiredSide, PrimaryVolume);
			return false;
		}

		if (_firstEntryPrice == 0m || _secondEntryPrice == 0m)
			return false;

		var primaryVolume = Math.Abs(primaryPosition);
		var secondaryVolume = Math.Abs(_secondPosition);

		var primaryProfit = primaryPosition > 0m
			? (firstClose - _firstEntryPrice) * primaryVolume
			: (_firstEntryPrice - firstClose) * primaryVolume;

		var secondaryProfit = _secondPosition > 0m
			? (secondClose - _secondEntryPrice) * secondaryVolume
			: (_secondEntryPrice - secondClose) * secondaryVolume;

		var totalProfit = primaryProfit + secondaryProfit;

		if (totalProfit >= TargetProfit)
		{
			LogInfo($"Target profit reached ({totalProfit:F2}). Closing both legs.");
			ClosePair();
		}

		return false;
	}

	private void OpenPair(Sides primarySide, Sides secondarySide, decimal secondaryVolume)
	{
		OpenSecondary(secondarySide, secondaryVolume);
		OpenPrimary(primarySide, PrimaryVolume);

		LogInfo($"Opened spread: {primarySide} {PrimaryVolume} {Security?.Code}, {secondarySide} {secondaryVolume} {SecondSecurity?.Code}.");
	}

	private void OpenPrimary(Sides side, decimal volume)
	{
		if (volume <= 0m)
			return;

		if (side == Sides.Buy)
			BuyMarket(volume);
		else
			SellMarket(volume);

		_firstEntryPrice = _lastFirstClose;
	}

	private void OpenSecondary(Sides side, decimal volume)
	{
		if (volume <= 0m || SecondSecurity == null || _secondPortfolio == null)
			return;

		var order = CreateOrder(side, _lastSecondClose, volume);
		order.Type = OrderTypes.Market;
		order.Security = SecondSecurity;
		order.Portfolio = _secondPortfolio;

		RegisterOrder(order);

		_secondPosition = side == Sides.Buy ? volume : -volume;
		_secondEntryPrice = _lastSecondClose;
	}

	private void ClosePair()
	{
		ClosePrimaryPosition();
		CloseSecondaryPosition();
	}

	private void ClosePrimaryPosition()
	{
		var primaryPosition = Position;

		if (primaryPosition > 0m)
			SellMarket(primaryPosition);
		else if (primaryPosition < 0m)
			BuyMarket(Math.Abs(primaryPosition));

		_firstEntryPrice = 0m;
	}

	private void CloseSecondaryPosition()
	{
		if (_secondPosition == 0m || SecondSecurity == null || _secondPortfolio == null)
			return;

		var side = _secondPosition > 0m ? Sides.Sell : Sides.Buy;
		var volume = Math.Abs(_secondPosition);

		var order = CreateOrder(side, _lastSecondClose, volume);
		order.Type = OrderTypes.Market;
		order.Security = SecondSecurity;
		order.Portfolio = _secondPortfolio;

		RegisterOrder(order);

		_secondPosition = 0m;
		_secondEntryPrice = 0m;
	}

	private decimal AdjustSecondaryVolume(decimal requestedVolume)
	{
		if (SecondSecurity == null)
			return 0m;

		var volume = Math.Abs(requestedVolume);
		var step = SecondSecurity.VolumeStep ?? 0m;

		if (step > 0m)
			volume = decimal.Floor(volume / step) * step;

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

		var max = SecondSecurity.MaxVolume;
		if (max != null && volume > max.Value)
			volume = max.Value;

		return volume;
	}

	private static void AppendHistory(List<decimal> storage, decimal value, int maxHistory)
	{
		storage.Add(value);

		if (storage.Count > maxHistory)
			storage.RemoveAt(0);
	}
}