GitHub で見る

統計的アービトラージ戦略

この統計的アービトラージアプローチは、移動平均周辺の相対的なポジションに基づいて関連する2つの有価証券ペアを取引します。各資産をその独自の平均と比較することで、時間をかけて収束するはずの短期的な乖離を利用することを目指します。

テストによると、平均年間リターンは約94%です。株式市場で最も高いパフォーマンスを発揮します。

最初の資産がその移動平均を下回り、2番目の資産がその独自の平均を上回って取引されているときにロングポジションが開始されます。最初の資産が平均を上回り、2番目が下回っているときにショートポジションが発生します。最初の資産がその移動平均に戻ると、スプレッドが正常化したことを示しポジションが決済されます。

この方法は、2つの銘柄間のエクスポージャーバランスに慣れた市場中立トレーダーに最適です。内蔵のストップロスは、スプレッドが反転する代わりにさらに拡大した場合のドローダウンを制限します。

詳細

  • エントリー条件:
    • ロング: Asset1 < MA1 && Asset2 > MA2
    • ショート: Asset1 > MA1 && Asset2 < MA2
  • ロング/ショート: 両方。
  • エグジット条件:
    • ロング: Asset1がMA1を上回って終値をつけた時に決済
    • ショート: Asset1がMA1を下回って終値をつけた時に決済
  • ストップ: あり、スプレッドに対するパーセンテージストップロス。
  • デフォルト値:
    • LookbackPeriod = 20
    • StopLossPercent = 2
    • CandleType = TimeSpan.FromMinutes(15)
    • SecondSecurity — 必須、既定値なし Asset1は戦略のSecurity、Asset2はSecondSecurityで、MA1とMA2は同時刻のローソク足における直近LookbackPeriod本の終値の単純移動平均です。ロングはAsset1を買いAsset2を売り、ショートはその逆で、各レッグはVolumeで取引され、逆のシグナルで両レッグがドテンします。ストップはスプレッド(Asset1 − Asset2)がエントリー時の値のStopLossPercentだけ不利に動いたときに両レッグを閉じ、ローソク足の終値で確認されます。0で無効になります。
  • フィルター:
    • カテゴリ: アービトラージ
    • 方向: 両方
    • インジケーター: Moving Averages
    • ストップ: あり
    • 複雑さ: 中級
    • 時間軸: イントラデイ
    • 季節性: いいえ
    • ニューラルネットワーク: いいえ
    • ダイバージェンス: はい
    • リスクレベル: 中
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>
/// Statistical arbitrage strategy.
/// Each instrument is compared with the simple moving average of its last LookbackPeriod closes on candles of the same time. The first
/// instrument below its average while the second is above its own buys the first and sells the second, the mirror does the opposite,
/// reversing an opposite pair. Both legs close once the first instrument closes back across its average, or once the spread, the first
/// close minus the second, moves StopLossPercent of its entry value against the pair.
/// </summary>
public class StatisticalArbitrageStrategy : Strategy
{
	private readonly StrategyParam<int> _lookbackPeriod;
	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> _firstHistory = [];
	private readonly Queue<decimal> _secondHistory = [];
	// 1 while long the spread, -1 while short it, 0 while flat.
	private int _side;
	private decimal _entrySpread;

	/// <summary>
	/// Number of closes each moving average spans.
	/// </summary>
	public int LookbackPeriod
	{
		get => _lookbackPeriod.Value;
		set => _lookbackPeriod.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 StatisticalArbitrageStrategy()
	{
		_lookbackPeriod = Param(nameof(LookbackPeriod), 20)
			.SetGreaterThanZero()
			.SetDisplay("Lookback Period", "Closes each moving average spans", "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(15).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();
		_firstHistory.Clear();
		_secondHistory.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);

		_firstHistory.Enqueue(first);
		_secondHistory.Enqueue(second);

		if (_firstHistory.Count > LookbackPeriod)
		{
			_firstHistory.Dequeue();
			_secondHistory.Dequeue();
		}

		if (_firstHistory.Count < LookbackPeriod)
			return;

		var firstAverage = _firstHistory.Average();
		var secondAverage = _secondHistory.Average();
		var spread = first - second;

		if (!IsFormedAndOnlineAndAllowTrading())
			return;

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

		if (first < firstAverage && second > secondAverage && _side <= 0)
		{
			MoveLegs(1);
			_entrySpread = spread;
		}
		else if (first > firstAverage && second < secondAverage && _side >= 0)
		{
			MoveLegs(-1);
			_entrySpread = spread;
		}
		else if (_side > 0 && (first > firstAverage || (StopLossPercent > 0 && spread <= _entrySpread - stopDistance)))
		{
			MoveLegs(0);
		}
		else if (_side < 0 && (first < firstAverage || (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);
	}
}