Auf GitHub ansehen

Strategie der Abgrenzungsanomalie

Die Accrual Anomaly-Strategie implementiert den Abgrenzungsanomalie-Faktor. Sie wird jährlich am ersten Handelstag im Mai neu gewichtet, wobei Aktien mit niedrigen Abgrenzungen long und Aktien mit hohen Abgrenzungen short gegangen wird.

Tests zeigen eine durchschnittliche jährliche Rendite von etwa 12%. Sie funktioniert am besten auf dem US-Aktienmarkt.

Positionen werden einmal pro Jahr angepasst; es werden keine Intraday-Signale verwendet.

Details

  • Einstiegskriterien: siehe Implementierung für Abgrenzungsberechnungen.
  • Long/Short: Beide Richtungen.
  • Ausstiegskriterien: Neugewichtung am nächsten geplanten Datum.
  • Stops: Keine explizite Stop-Logik.
  • Standardwerte:
    • Deciles = 10
    • CandleType = TimeSpan.FromMinutes(5).TimeFrame()
  • Filter:
    • Kategorie: Fundamental
    • Richtung: Beide
    • Indikatoren: Fundamentals
    • Stops: Nein
    • Komplexität: Mittel
    • Zeitrahmen: Täglich
    • 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>
/// Cross-sectional accrual anomaly strategy that trades the primary instrument against a synthetic accrual benchmark derived from the secondary instrument.
/// </summary>
public class AccrualAnomalyStrategy : Strategy
{
	private readonly StrategyParam<string> _security2Id;
	private readonly StrategyParam<int> _accrualLength;
	private readonly StrategyParam<int> _lookbackPeriod;
	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 _security2 = null!;
	private ExponentialMovingAverage _primaryAccrualAverage = null!;
	private ExponentialMovingAverage _secondaryAccrualAverage = null!;
	private SimpleMovingAverage _spreadAverage = null!;
	private StandardDeviation _spreadDeviation = null!;
	private decimal _latestPrimaryAccrual;
	private decimal _latestSecondaryAccrual;
	private bool _primaryUpdated;
	private bool _secondaryUpdated;
	private decimal? _previousZScore;
	private int _cooldownRemaining;

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

	/// <summary>
	/// Smoothing length for the synthetic accrual proxy.
	/// </summary>
	public int AccrualLength
	{
		get => _accrualLength.Value;
		set => _accrualLength.Value = value;
	}

	/// <summary>
	/// Lookback period for spread normalization.
	/// </summary>
	public int LookbackPeriod
	{
		get => _lookbackPeriod.Value;
		set => _lookbackPeriod.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 both instruments.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	/// <summary>
	/// Initializes strategy parameters.
	/// </summary>
	public AccrualAnomalyStrategy()
	{
		_security2Id = Param(nameof(Security2Id), Paths.HistoryDefaultSecurity2)
			.SetDisplay("Second Security Id", "Identifier of the secondary security", "General");

		_accrualLength = Param(nameof(AccrualLength), 6)
			.SetRange(2, 30)
			.SetDisplay("Accrual Length", "Smoothing length for the synthetic accrual proxy", "Indicators");

		_lookbackPeriod = Param(nameof(LookbackPeriod), 24)
			.SetRange(10, 120)
			.SetDisplay("Lookback Period", "Lookback period for spread normalization", "Indicators");

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

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

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

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

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
			.SetDisplay("Candle Type", "Candle series for both instruments", "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();

		_security2 = null!;
		_primaryAccrualAverage = null!;
		_secondaryAccrualAverage = null!;
		_spreadAverage = null!;
		_spreadDeviation = null!;
		_latestPrimaryAccrual = 0m;
		_latestSecondaryAccrual = 0m;
		_primaryUpdated = false;
		_secondaryUpdated = false;
		_previousZScore = null;
		_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("Secondary security identifier is not specified.");

		_security2 = this.LookupById(Security2Id) ?? new Security { Id = Security2Id };
		_primaryAccrualAverage = new ExponentialMovingAverage { Length = AccrualLength };
		_secondaryAccrualAverage = new ExponentialMovingAverage { Length = AccrualLength };
		_spreadAverage = new SimpleMovingAverage { Length = LookbackPeriod };
		_spreadDeviation = new StandardDeviation { Length = LookbackPeriod };
		_cooldownRemaining = 0;

		var primarySubscription = SubscribeCandles(CandleType, security: Security);
		var secondarySubscription = SubscribeCandles(CandleType, security: _security2);

		primarySubscription
			.Bind(ProcessPrimaryCandle)
			.Start();

		secondarySubscription
			.Bind(ProcessSecondaryCandle)
			.Start();

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

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

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

		_latestPrimaryAccrual = UpdateAccrualAverage(_primaryAccrualAverage, candle);
		_primaryUpdated = true;
		TryProcessSpread(candle.OpenTime);
	}

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

		_latestSecondaryAccrual = UpdateAccrualAverage(_secondaryAccrualAverage, candle);
		_secondaryUpdated = true;
		TryProcessSpread(candle.OpenTime);
	}

	private decimal UpdateAccrualAverage(ExponentialMovingAverage average, ICandleMessage candle)
	{
		var accrualProxy = CalculateAccrualProxy(candle);
		return average.Process(accrualProxy, candle.OpenTime, true).ToDecimal();
	}

	private decimal CalculateAccrualProxy(ICandleMessage candle)
	{
		var priceBase = Math.Max(candle.OpenPrice, 1m);
		var range = Math.Max(candle.HighPrice - candle.LowPrice, Security?.PriceStep ?? 1m);
		var body = candle.ClosePrice - candle.OpenPrice;
		var bodyRatio = body / priceBase;
		var closeLocation = ((candle.ClosePrice - candle.LowPrice) - (candle.HighPrice - candle.ClosePrice)) / range;
		var volatilityRatio = range / priceBase;

		return (bodyRatio * 18m) + (closeLocation * 0.8m) - (volatilityRatio * 6m);
	}

	private void TryProcessSpread(DateTime time)
	{
		if (!_primaryUpdated || !_secondaryUpdated)
			return;

		_primaryUpdated = false;
		_secondaryUpdated = false;

		if (!_primaryAccrualAverage.IsFormed || !_secondaryAccrualAverage.IsFormed)
			return;

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

		if (!_spreadAverage.IsFormed || !_spreadDeviation.IsFormed || deviation <= 0)
			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;
	}
}