Ver no GitHub

Estratégia Spreader 2

Visão geral

A Estratégia Spreader 2 é um sistema de trading de pares convertido do consultor especialista de MetaTrader "Spreader 2". Ela observa dois instrumentos correlacionados em um período de um minuto e busca desvios de curto prazo entre seus movimentos de preço. Quando ambas as pernas divergem dentro de limites de volatilidade controlada mantendo correlação positiva, a estratégia abre um spread de mercado neutro indo comprado em um símbolo e vendido no outro. A posição combinada é fechada quando o lucro flutuante total atinge o alvo configurado ou quando as regras de correlação são violadas.

Lógica principal

  1. Receber candles finalizados para os símbolos primário e secundário e alinhá-los por tempo de fechamento.
  2. Manter listas contínuas de preços de fechamento para que o algoritmo possa referenciar valores que estão ShiftLength, 2 * ShiftLength e 1440 barras no passado.
  3. Calcular primeiras diferenças (x1, x2 para o símbolo primário e y1, y2 para o símbolo secundário) para detectar oscilações locais.
  4. Pular negociações quando qualquer instrumento mostra dois movimentos consecutivos na mesma direção (filtro de tendência) ou quando os produtos x1 * y1 indicam correlação negativa.
  5. Avaliar a razão de volatilidade a / b onde a = |x1| + |x2| e b = |y1| + |y2|. Prosseguir apenas quando a razão permanece entre 0.3 e 3.0.
  6. Escalar o volume da perna secundária proporcionalmente à razão de volatilidade e ajustá-lo ao passo de volume do contrato, mínimo e máximo.
  7. Confirmar a direção de negociação pretendida com o histórico de 1440 barras (aproximadamente um dia de negociação). O spread só é aberto quando o movimento diário apoia o sinal de curto prazo.
  8. A estratégia abre ambas as pernas simultaneamente: o símbolo primário negocia com o PrimaryVolume configurado, enquanto o símbolo secundário negocia o tamanho ajustado na direção oposta.
  9. Enquanto as posições estão abertas, o sistema rastreia continuamente o lucro flutuante de ambas as pernas. Quando o lucro combinado excede TargetProfit, ele fecha o spread e redefine as referências de entrada.
  10. Verificações de segurança fecham automaticamente posições órfãs se uma perna sair inesperadamente e reabre as pernas ausentes quando possível para manter o hedge equilibrado.

Parâmetros

  • SecondSecurity – instrumento secundário participante do spread. Este parâmetro é obrigatório.
  • PrimaryVolume – volume de negociação (em lotes/contratos) para o símbolo primário. O padrão é 1.
  • TargetProfit – alvo de lucro monetário absoluto para o par combinado. O padrão é 100.
  • ShiftLength – número de candles entre os pontos de comparação usados nos cálculos de primeira diferença. O padrão é 30.
  • CandleType – tipo de dado usado para assinaturas de candles. Por padrão a estratégia trabalha com candles de um minuto.

Regras de negociação

  • Apenas candles finalizados são processados para evitar ações em dados incompletos.
  • Os filtros de tendência devem mostrar movimentos opostos nas últimas duas janelas de ShiftLength para ambos os símbolos.
  • A correlação deve ser positiva, e a razão de volatilidade deve permanecer na banda [0.3, 3.0].
  • A verificação de confirmação contra o histórico de 1440 barras previne negociações que contradizem a direção de longo prazo.
  • As ordens são enviadas com OrderTypes.Market. A perna secundária é registrada explicitamente com o instrumento secundário e a carteira para espelhar o comportamento do MetaTrader.
  • O lucro aberto é calculado usando os últimos fechamentos de candles e os preços de entrada armazenados para determinar quando sair do spread.

Notas

  • A estratégia assume que ambos os instrumentos compartilham especificações de contrato compatíveis. Se os multiplicadores diferem, o trading é desabilitado e um aviso é registrado.
  • Como o algoritmo original depende de um dia completo de dados históricos, a versão StockSharp também espera até que pelo menos 1440 candles sejam acumulados antes da primeira entrada.
  • Toda a lógica de gestão de risco (alvo de lucro, tratamento de perna órfã) está contida na estratégia. Proteções adicionais como stop-losses podem ser adicionadas externamente se necessário.
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);
	}
}