在 GitHub 上查看

Pairs 策略

该策略监控两只相关资产之间的价差,并与其历史均值与标准差比较,捕捉暂时性偏离。

测试表明年均收益约为 88%,该策略在股票市场表现最佳。

当价差低于均值减去乘数倍标准差时做多价差(买入第一只资产卖出第二只资产);当价差高于均值加上乘数倍标准差时做空价差。价差回到均值附近时平仓。

此方法适合追求市场中性、擅长平衡两只合约敞口的交易者。若价差继续扩大,止损可以限制回撤。

细节

  • 入场条件:
    • 多头: Spread < Mean - Multiplier * StdDev
    • 空头: Spread > Mean + Multiplier * StdDev
  • 多/空: 双向
  • 离场条件:
    • 多头: 价差回到均值时平仓
    • 空头: 价差回到均值时平仓
  • 止损: 百分比止损,基于价差
  • 默认值:
    • LookbackPeriod = 20
    • DeviationMultiplier = 2
    • StopLossPercent = 2
    • CandleType = TimeSpan.FromMinutes(5)
    • SecondSecurity — 必填,无默认值 价差为策略主品种(Security)收盘价减去SecondSecurity在同一时间K线上的收盘价;Mean和StdDev取最近LookbackPeriod个价差计算。每条腿交易Volume数量,相反信号会同时反转两条腿。当价差朝不利方向移动入场价差的StopLossPercent百分比时止损平掉两条腿,按K线收盘检查;设为0则关闭。
  • 过滤器:
    • 类别: Arbitrage
    • 方向: 双向
    • 指标: Spread statistics
    • 止损: 是
    • 复杂度: 中等
    • 时间框架: 日内
    • 季节性: 否
    • 神经网络: 否
    • 背离: 是
    • 风险等级: 中等
using System;
using System.Collections.Generic;
using System.Linq;

using Ecng.Common;

using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Messages;

namespace StockSharp.Samples.Strategies;

/// <summary>
/// Pairs trading strategy.
/// The spread is the first instrument's close minus the second's on candles of the same time. A spread more than DeviationMultiplier
/// standard deviations below the mean of the last LookbackPeriod spreads buys the first instrument and sells the second, one more than
/// that above it does the opposite, reversing an opposite pair. Both legs close once the spread returns to the mean, or once it moves
/// StopLossPercent of its entry value against the pair.
/// </summary>
public class PairsTradingStrategy : Strategy
{
	private readonly StrategyParam<int> _lookbackPeriod;
	private readonly StrategyParam<decimal> _deviationMultiplier;
	private readonly StrategyParam<decimal> _stopLossPercent;
	private readonly StrategyParam<DataType> _candleType;
	private readonly StrategyParam<Security> _secondSecurity;

	private readonly Dictionary<DateTime, decimal> _firstCloses = [];
	private readonly Dictionary<DateTime, decimal> _secondCloses = [];
	private readonly Queue<decimal> _spreads = [];
	// 1 while long the spread, -1 while short it, 0 while flat.
	private int _side;
	private decimal _entrySpread;

	/// <summary>
	/// Number of spreads the mean and standard deviation are measured over.
	/// </summary>
	public int LookbackPeriod
	{
		get => _lookbackPeriod.Value;
		set => _lookbackPeriod.Value = value;
	}

	/// <summary>
	/// Standard deviations between the mean and an entry level.
	/// </summary>
	public decimal DeviationMultiplier
	{
		get => _deviationMultiplier.Value;
		set => _deviationMultiplier.Value = value;
	}

	/// <summary>
	/// Adverse spread move, in percent of the entry spread, that closes the pair.
	/// </summary>
	public decimal StopLossPercent
	{
		get => _stopLossPercent.Value;
		set => _stopLossPercent.Value = value;
	}

	/// <summary>
	/// Candle type.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	/// <summary>
	/// Second security in the pair.
	/// </summary>
	public Security SecondSecurity
	{
		get => _secondSecurity.Value;
		set => _secondSecurity.Value = value;
	}

	/// <summary>
	/// Constructor.
	/// </summary>
	public PairsTradingStrategy()
	{
		_lookbackPeriod = Param(nameof(LookbackPeriod), 20)
			.SetGreaterThanZero()
			.SetDisplay("Lookback Period", "Spreads the mean and standard deviation are measured over", "Parameters");

		_deviationMultiplier = Param(nameof(DeviationMultiplier), 2m)
			.SetGreaterThanZero()
			.SetDisplay("Deviation Multiplier", "Standard deviations between the mean and an entry level", "Parameters");

		_stopLossPercent = Param(nameof(StopLossPercent), 2m)
			.SetNotNegative()
			.SetDisplay("Stop-loss %", "Adverse spread move in percent of the entry spread", "Risk Management");

		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(5).TimeFrame())
			.SetDisplay("Candle Type", "Type of candles to use", "General");

		_secondSecurity = Param<Security>(nameof(SecondSecurity))
			.SetDisplay("Second Security", "Second security in the pair", "General")
			.SetRequired();
	}

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

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

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

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

		ResetState();

		var firstSubscription = SubscribeCandles(CandleType);
		firstSubscription.Bind(candle => ProcessCandle(candle, _firstCloses)).Start();

		var secondSubscription = SubscribeCandles(CandleType, security: SecondSecurity);
		secondSubscription.Bind(candle => ProcessCandle(candle, _secondCloses)).Start();

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

	private void ResetState()
	{
		_firstCloses.Clear();
		_secondCloses.Clear();
		_spreads.Clear();
		_side = 0;
		_entrySpread = 0;
	}

	private void ProcessCandle(ICandleMessage candle, Dictionary<DateTime, decimal> closes)
	{
		if (candle.State != CandleStates.Finished)
			return;

		closes[candle.OpenTime] = candle.ClosePrice;

		// The spread needs both instruments' candles of the same time, whichever arrives last.
		if (!_firstCloses.TryGetValue(candle.OpenTime, out var first) || !_secondCloses.TryGetValue(candle.OpenTime, out var second))
			return;

		foreach (var stale in _firstCloses.Keys.Where(t => t <= candle.OpenTime).ToArray())
			_firstCloses.Remove(stale);

		foreach (var stale in _secondCloses.Keys.Where(t => t <= candle.OpenTime).ToArray())
			_secondCloses.Remove(stale);

		var spread = first - second;

		_spreads.Enqueue(spread);

		if (_spreads.Count > LookbackPeriod)
			_spreads.Dequeue();

		if (_spreads.Count < LookbackPeriod)
			return;

		var mean = _spreads.Average();
		var deviation = (decimal)Math.Sqrt((double)_spreads.Average(s => (s - mean) * (s - mean)));
		var upper = mean + DeviationMultiplier * deviation;
		var lower = mean - DeviationMultiplier * deviation;

		if (!IsFormedAndOnlineAndAllowTrading())
			return;

		var stopDistance = Math.Abs(_entrySpread) * StopLossPercent / 100;

		if (spread < lower && _side <= 0)
		{
			MoveLegs(1);
			_entrySpread = spread;
		}
		else if (spread > upper && _side >= 0)
		{
			MoveLegs(-1);
			_entrySpread = spread;
		}
		else if (_side > 0 && (spread >= mean || (StopLossPercent > 0 && spread <= _entrySpread - stopDistance)))
		{
			MoveLegs(0);
		}
		else if (_side < 0 && (spread <= mean || (StopLossPercent > 0 && spread >= _entrySpread + stopDistance)))
		{
			MoveLegs(0);
		}
	}

	private void MoveLegs(int side)
	{
		_side = side;

		// Long the spread holds the first instrument and is short the second, each by Volume.
		var firstChange = side * Volume - Position;

		if (firstChange > 0)
			BuyMarket(firstChange);
		else if (firstChange < 0)
			SellMarket(-firstChange);

		var secondChange = -side * Volume - (GetPositionValue(SecondSecurity, Portfolio) ?? 0m);

		if (secondChange > 0)
			BuyMarket(secondChange, SecondSecurity);
		else if (secondChange < 0)
			SellMarket(-secondChange, SecondSecurity);
	}
}