Auf GitHub ansehen

Rendite-Asymmetrie-Strategie für Rohstoffe

Die Strategie Rendite-Asymmetrie für Rohstoffe nutzt den Unterschied zwischen positiven und negativen Renditen. Für jeden Rohstoff-Future summiert das rollende Fenster alle Aufwärts- und Abwärtsbewegungen separat. Ein hohes Verhältnis deutet auf einen anhaltend positiven Drift hin, während ein niedriges Verhältnis auf anhaltenden Verkaufsdruck hindeutet.

Zu Beginn jedes Monats werden Rohstoffe nach diesem Asymmetriemaß gerankt. Das System kauft die N besten Kontrakte und verkauft die N schwächsten leer, wobei das Kapital gleichmäßig verteilt wird. Das Rebalancing erfolgt monatlich.

Details

  • Einstiegskriterien: Monatliches Ranking der Asymmetrie der täglichen Renditen über ein Lookback-Fenster.
  • Long/Short: Beide Richtungen.
  • Ausstiegskriterien: Positionen beim monatlichen Rebalancing angepasst.
  • Stops: Kein expliziter Stop; Positionsgröße durch MinTradeUsd begrenzt.
  • Standardwerte:
    • WindowDays = 120
    • TopN = 5
    • MinTradeUsd = 200
    • CandleType = TimeSpan.FromMinutes(5).TimeFrame()
  • Filter:
    • Kategorie: Momentum
    • Richtung: Beide
    • Indikatoren: Preisbasiert
    • Stops: Nein
    • Komplexität: Mittel
    • Zeitrahmen: Mittelfristig
    • Saisonalität: Ja
    • Neuronale Netze: Nein
    • Divergenz: Nein
    • Risikolevel: Mittel
using System;
using System.Collections.Generic;

using Ecng.Common;

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

namespace StockSharp.Samples.Strategies;

/// <summary>
/// Return asymmetry strategy that trades the primary commodity when its positive-versus-negative return balance diverges from a benchmark commodity.
/// </summary>
public class ReturnAsymmetryCommodityStrategy : Strategy
{
	private readonly StrategyParam<string> _security2Id;
	private readonly StrategyParam<int> _windowLength;
	private readonly StrategyParam<int> _normalizationPeriod;
	private readonly StrategyParam<decimal> _entryThreshold;
	private readonly StrategyParam<decimal> _exitThreshold;
	private readonly StrategyParam<int> _cooldownBars;
	private readonly StrategyParam<decimal> _stopLoss;
	private readonly StrategyParam<DataType> _candleType;

	private Security _benchmark = null!;
	private SimpleMovingAverage _spreadAverage = null!;
	private StandardDeviation _spreadDeviation = null!;
	private readonly Queue<decimal> _primaryReturns = [];
	private readonly Queue<decimal> _benchmarkReturns = [];
	private decimal? _previousPrimaryClose;
	private decimal? _previousBenchmarkClose;
	private decimal? _previousZScore;
	private decimal _latestPrimaryAsymmetry;
	private decimal _latestBenchmarkAsymmetry;
	private bool _primaryUpdated;
	private bool _benchmarkUpdated;
	private int _cooldownRemaining;

	/// <summary>
	/// Benchmark security identifier.
	/// </summary>
	public string Security2Id
	{
		get => _security2Id.Value;
		set => _security2Id.Value = value;
	}

	/// <summary>
	/// Lookback period used to compute return asymmetry.
	/// </summary>
	public int WindowLength
	{
		get => _windowLength.Value;
		set => _windowLength.Value = value;
	}

	/// <summary>
	/// Lookback period used to normalize the asymmetry spread.
	/// </summary>
	public int NormalizationPeriod
	{
		get => _normalizationPeriod.Value;
		set => _normalizationPeriod.Value = value;
	}

	/// <summary>
	/// Z-score threshold required to open a position.
	/// </summary>
	public decimal EntryThreshold
	{
		get => _entryThreshold.Value;
		set => _entryThreshold.Value = value;
	}

	/// <summary>
	/// Z-score threshold required to close a position.
	/// </summary>
	public decimal ExitThreshold
	{
		get => _exitThreshold.Value;
		set => _exitThreshold.Value = value;
	}

	/// <summary>
	/// Closed candles to wait before another position change.
	/// </summary>
	public int CooldownBars
	{
		get => _cooldownBars.Value;
		set => _cooldownBars.Value = value;
	}

	/// <summary>
	/// Stop loss percentage.
	/// </summary>
	public decimal StopLoss
	{
		get => _stopLoss.Value;
		set => _stopLoss.Value = value;
	}

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

	public ReturnAsymmetryCommodityStrategy()
	{
		_security2Id = Param(nameof(Security2Id), Paths.HistoryDefaultSecurity2)
			.SetDisplay("Benchmark Security Id", "Identifier of the benchmark commodity", "General");

		_windowLength = Param(nameof(WindowLength), 20)
			.SetRange(5, 120)
			.SetDisplay("Window Length", "Lookback period used to compute return asymmetry", "Indicators");

		_normalizationPeriod = Param(nameof(NormalizationPeriod), 16)
			.SetRange(5, 120)
			.SetDisplay("Normalization Period", "Lookback period used to normalize the asymmetry spread", "Indicators");

		_entryThreshold = Param(nameof(EntryThreshold), 1.1m)
			.SetRange(0.2m, 5m)
			.SetDisplay("Entry Threshold", "Z-score threshold required to open a position", "Signals");

		_exitThreshold = Param(nameof(ExitThreshold), 0.25m)
			.SetRange(0m, 2m)
			.SetDisplay("Exit Threshold", "Z-score threshold required to close a position", "Signals");

		_cooldownBars = Param(nameof(CooldownBars), 8)
			.SetRange(0, 120)
			.SetDisplay("Cooldown Bars", "Closed candles to wait before another position change", "Risk");

		_stopLoss = Param(nameof(StopLoss), 3m)
			.SetRange(0.5m, 10m)
			.SetDisplay("Stop Loss %", "Stop loss percentage", "Risk");

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
			.SetDisplay("Candle Type", "Time frame for candles", "General");
	}

	/// <inheritdoc />
	public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
	{
		if (Security != null)
			yield return (Security, CandleType);

		if (!Security2Id.IsEmpty())
			yield return (new Security { Id = Security2Id }, CandleType);
	}

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

		_benchmark = null!;
		_spreadAverage = null!;
		_spreadDeviation = null!;
		_primaryReturns.Clear();
		_benchmarkReturns.Clear();
		_previousPrimaryClose = null;
		_previousBenchmarkClose = null;
		_previousZScore = null;
		_latestPrimaryAsymmetry = 0m;
		_latestBenchmarkAsymmetry = 0m;
		_primaryUpdated = false;
		_benchmarkUpdated = false;
		_cooldownRemaining = 0;
	}

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

		if (Security == null)
			throw new InvalidOperationException("Primary security is not specified.");

		if (Security2Id.IsEmpty())
			throw new InvalidOperationException("Benchmark security identifier is not specified.");

		_benchmark = this.LookupById(Security2Id) ?? new Security { Id = Security2Id };
		_spreadAverage = new SimpleMovingAverage { Length = NormalizationPeriod };
		_spreadDeviation = new StandardDeviation { Length = NormalizationPeriod };

		var primarySubscription = SubscribeCandles(CandleType, security: Security);
		var benchmarkSubscription = SubscribeCandles(CandleType, security: _benchmark);

		primarySubscription
			.Bind(ProcessPrimaryCandle)
			.Start();

		benchmarkSubscription
			.Bind(ProcessBenchmarkCandle)
			.Start();

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

		StartProtection(
			new Unit(2, UnitTypes.Percent),
			new Unit(StopLoss, UnitTypes.Percent));
	}

	private void ProcessPrimaryCandle(ICandleMessage candle)
	{
		if (candle.State != CandleStates.Finished)
			return;

		var ret = UpdateReturns(_primaryReturns, candle.ClosePrice, ref _previousPrimaryClose);
		if (ret is null)
			return;

		_latestPrimaryAsymmetry = CalculateAsymmetry(_primaryReturns);
		_primaryUpdated = true;
		TryProcessSpread(candle.OpenTime);
	}

	private void ProcessBenchmarkCandle(ICandleMessage candle)
	{
		if (candle.State != CandleStates.Finished)
			return;

		var ret = UpdateReturns(_benchmarkReturns, candle.ClosePrice, ref _previousBenchmarkClose);
		if (ret is null)
			return;

		_latestBenchmarkAsymmetry = CalculateAsymmetry(_benchmarkReturns);
		_benchmarkUpdated = true;
		TryProcessSpread(candle.OpenTime);
	}

	private decimal? UpdateReturns(Queue<decimal> queue, decimal closePrice, ref decimal? previousClose)
	{
		if (previousClose is not decimal previous || previous <= 0m)
		{
			previousClose = closePrice;
			return null;
		}

		var ret = (closePrice - previous) / previous;
		previousClose = closePrice;

		if (queue.Count == WindowLength)
			queue.Dequeue();

		queue.Enqueue(ret);
		return ret;
	}

	private static decimal CalculateAsymmetry(IEnumerable<decimal> returns)
	{
		decimal positive = 0m;
		decimal negative = 0m;

		foreach (var ret in returns)
		{
			if (ret > 0m)
				positive += ret;
			else
				negative += Math.Abs(ret);
		}

		return positive / Math.Max(negative, 0.0001m);
	}

	private void TryProcessSpread(DateTime time)
	{
		if (!_primaryUpdated || !_benchmarkUpdated || _primaryReturns.Count < WindowLength || _benchmarkReturns.Count < WindowLength)
			return;

		_primaryUpdated = false;
		_benchmarkUpdated = false;

		var spread = _latestPrimaryAsymmetry - _latestBenchmarkAsymmetry;
		var mean = _spreadAverage.Process(spread, time, true).ToDecimal();
		var deviation = _spreadDeviation.Process(spread, time, true).ToDecimal();

		if (!_spreadAverage.IsFormed || !_spreadDeviation.IsFormed || deviation <= 0m)
			return;

		if (!IsFormedAndOnlineAndAllowTrading())
			return;

		if (_cooldownRemaining > 0)
			_cooldownRemaining--;

		var zScore = (spread - mean) / deviation;
		var bullishEntry = _previousZScore is decimal previousBullish && previousBullish < EntryThreshold && zScore >= EntryThreshold;
		var bearishEntry = _previousZScore is decimal previousBearish && previousBearish > -EntryThreshold && zScore <= -EntryThreshold;

		if (_cooldownRemaining == 0 && Position == 0)
		{
			if (bullishEntry)
			{
				BuyMarket();
				_cooldownRemaining = CooldownBars;
			}
			else if (bearishEntry)
			{
				SellMarket();
				_cooldownRemaining = CooldownBars;
			}
		}
		else if (Position > 0 && zScore <= ExitThreshold)
		{
			SellMarket(Position);
			_cooldownRemaining = CooldownBars;
		}
		else if (Position < 0 && zScore >= -ExitThreshold)
		{
			BuyMarket(Math.Abs(Position));
			_cooldownRemaining = CooldownBars;
		}

		_previousZScore = zScore;
	}
}