在 GitHub 上查看

Delta Neutral Arbitrage 策略

该套利策略在兩個相關資產之間交易價差,保持整體頭寸接近 Delta 中性。通過在一個資產做多、另一個資產做空,期望從價差的均值回歸中獲利,而非單純依賴市場方向。

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

當價差的 Z 分數低於 -EntryThreshold 時建立多頭價差:買入第一個資產並等量賣出第二個資產。當 Z 分數高於正閾值時則做相反操作。當價差回到移動平均線附近時平倉。

Delta 中性交易受到尋求低波動敞口的量化交易者歡迎。即使已經對沖,仍會使用止損來防止資產間出現極端背離。

详细信息

  • 入場條件:
    • 做多: 價差 Z 分數 < -EntryThreshold
    • 做空: 價差 Z 分數 > EntryThreshold
  • 多空方向: 雙向
  • 退出條件:
    • 做多: 價差上穿均值時平倉
    • 做空: 價差下穿均值時平倉
  • 止損: 是,按價差百分比止損
  • 默認值:
    • LookbackPeriod = 20
    • EntryThreshold = 2
    • StopLossPercent = 2
    • CandleType = TimeSpan.FromMinutes(5)
    • Asset2Security — 必填,无默认值
    • Asset2Portfolio — 为空时使用策略的投资组合 价差为策略主品种(Security)收盘价减去Asset2Security在同一时间K线上的收盘价,其z-score基于最近LookbackPeriod个价差的均值和标准差。每条腿交易Volume数量,相反信号会同时反转两条腿。当价差朝不利方向移动入场价差的StopLossPercent百分比时止损平掉两条腿,按K线收盘检查;设为0则关闭。
  • 篩選條件:
    • 類別: 套利
    • 方向: 雙向
    • 指標: 價差統計
    • 止損: 是
    • 複雜度: 中等
    • 時間框架: 日內
    • 季節性: 否
    • 神經網絡: 否
    • 背離: 是
    • 風險等級: 中
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>
/// Delta neutral arbitrage strategy.
/// The spread is the first instrument's close minus Asset2Security's on candles of the same time, and its z-score is measured against the
/// mean and standard deviation of the last LookbackPeriod spreads. A z-score below minus EntryThreshold buys the first instrument and sells
/// Asset2Security in equal size, one above EntryThreshold does the opposite, reversing an opposite pair. Both legs close once the spread
/// crosses back over its mean, or once it moves StopLossPercent of its entry value against the pair.
/// </summary>
public class DeltaNeutralArbitrageStrategy : Strategy
{
	private readonly StrategyParam<Security> _asset2Security;
	private readonly StrategyParam<Portfolio> _asset2Portfolio;
	private readonly StrategyParam<int> _lookbackPeriod;
	private readonly StrategyParam<decimal> _entryThreshold;
	private readonly StrategyParam<decimal> _stopLossPercent;
	private readonly StrategyParam<DataType> _candleType;

	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>
	/// Second asset of the pair.
	/// </summary>
	public Security Asset2Security
	{
		get => _asset2Security.Value;
		set => _asset2Security.Value = value;
	}

	/// <summary>
	/// Portfolio for the second asset; the strategy's own portfolio when empty.
	/// </summary>
	public Portfolio Asset2Portfolio
	{
		get => _asset2Portfolio.Value;
		set => _asset2Portfolio.Value = value;
	}

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

	/// <summary>
	/// Z-score distance from zero that opens a pair.
	/// </summary>
	public decimal EntryThreshold
	{
		get => _entryThreshold.Value;
		set => _entryThreshold.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>
	/// Constructor.
	/// </summary>
	public DeltaNeutralArbitrageStrategy()
	{
		_asset2Security = Param<Security>(nameof(Asset2Security))
			.SetDisplay("Asset 2", "Second asset of the pair", "Securities")
			.SetRequired();

		_asset2Portfolio = Param<Portfolio>(nameof(Asset2Portfolio))
			.SetDisplay("Portfolio 2", "Portfolio for the second asset", "Portfolios");

		_lookbackPeriod = Param(nameof(LookbackPeriod), 20)
			.SetGreaterThanZero()
			.SetDisplay("Lookback Period", "Spreads the mean and standard deviation are measured over", "Parameters");

		_entryThreshold = Param(nameof(EntryThreshold), 2m)
			.SetGreaterThanZero()
			.SetDisplay("Entry Threshold", "Z-score distance from zero that opens a pair", "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");
	}

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

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

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

		if (Asset2Security == null)
			throw new InvalidOperationException("Asset2Security is not specified.");

		ResetState();

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

		var secondSubscription = SubscribeCandles(CandleType, security: Asset2Security);
		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)));

		if (deviation == 0)
			return;

		var zScore = (spread - mean) / deviation;

		if (!IsFormedAndOnlineAndAllowTrading())
			return;

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

		if (zScore < -EntryThreshold && _side <= 0)
		{
			MoveLegs(1);
			_entrySpread = spread;
		}
		else if (zScore > EntryThreshold && _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 portfolio = Asset2Portfolio ?? Portfolio;
		var secondChange = -side * Volume - (GetPositionValue(Asset2Security, portfolio) ?? 0m);

		if (secondChange != 0)
		{
			RegisterOrder(new Order
			{
				Security = Asset2Security,
				Portfolio = portfolio,
				Side = secondChange > 0 ? Sides.Buy : Sides.Sell,
				Volume = Math.Abs(secondChange),
				Type = OrderTypes.Market,
			});
		}
	}
}