在 GitHub 上查看

Spreader 2 策略

概述

Spreader 2 策略 是从 MetaTrader 专家顾问“Spreader 2”移植而来的配对交易系统。策略在 1 分钟周期内监控两个相关品种的 K 线,当两者的短期波动在受控波动率范围内出现背离但仍保持正相关时,系统会同时买入一只、卖出另一只,建立市场中性头寸。当组合的浮动利润达到设定目标或相关性条件失效时,策略会平掉整个价差。

核心逻辑

  1. 接收主品种和副品种的收盘完成 K 线,并根据收盘时间对齐。
  2. 维护两个品种的滚动收盘价序列,便于引用 ShiftLength2 * ShiftLength 以及 1440 根之前的数据。
  3. 计算主品种的 x1x2 与副品种的 y1y2 一阶差分,用于识别短期波动方向。
  4. 若任一品种连续两段同方向波动(趋势过滤)或 x1 * y1 为负(负相关),则放弃交易。
  5. 计算波动率比值 a / b(其中 a = |x1| + |x2|b = |y1| + |y2|),仅当比值处于 0.33.0 范围内时才继续。
  6. 按照波动率比值缩放副品种仓位,并根据合约的最小成交量、步长与上限进行调整。
  7. 使用 1440 根(约一天)历史数据确认方向,仅在长周期走势支持短周期信号时才开仓。
  8. 同时开仓两个腿:主品种使用 PrimaryVolume 设定的手数,副品种使用调整后的手数反向交易。
  9. 持仓期间实时累计两个腿的浮动盈亏,当总盈亏超过 TargetProfit 时立刻平仓并清空入场参考价。
  10. 若出现单腿意外平仓,保护逻辑会自动关闭另一条腿或在条件允许时重新补仓,以保持价差对冲结构。

参数

  • SecondSecurity:参与价差的副品种(必填)。
  • PrimaryVolume:主品种的下单手数,默认 1
  • TargetProfit:组合浮动利润目标(绝对金额),默认 100
  • ShiftLength:差分计算时引用的间隔 K 线数量,默认 30
  • CandleType:订阅的 K 线类型,默认使用 1 分钟时间框架。

交易规则

  • 仅处理状态为完成的 K 线,避免基于未结束数据下单。
  • 两个品种最近两段的波动方向必须相反,才能视为回归机会。
  • 相关性必须为正,且波动率比值需保持在 [0.3, 3.0] 区间。
  • 1440 根的长周期检查必须支持当前方向,否则信号作废。
  • 使用市价单开仓,副品种委托明确指定证券与组合,以匹配原始脚本的行为。
  • 结合最新收盘价与记录的入场价计算浮动盈亏,从而判断是否达到获利目标。

备注

  • 策略假设两个品种的合约乘数兼容。若乘数不一致,策略会记录警告并停止交易。
  • 由于原始算法需要完整的一天历史数据,移植版本同样会等待至少 1440 根 K 线后才允许首次进场。
  • 若需要额外的风险控制(例如止损、限时平仓),可在策略外部叠加其他风控组件。
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);
	}
}