GitHub で見る

平均回帰 Donchian 戦略

概要

この戦略は、MetaTrader エキスパート アドバイザー MeanReversion.mq5 の移植版です。これは単純な平均回帰パターンで取引されます。選択したルックバック ウィンドウ内で価格が新たな安値を更新するたびに、この戦略は最近のレンジの中間点をターゲットとしてロング ポジションをオープンします。新しい高値が出現すると、戦略はショートサイドのロジックを反映します。ポジションサイズはリスクパーセンテージとストップ距離から計算され、元の EA が実行するロット計算を厳密に複製します。

取引ロジック

  1. 構成されたローソクのタイプとルックバック期間を使用して、Donchian チャネルを構築します。上のバンドは窓上の最高高値を示し、下のバンドは窓上の最低安値を示します。中間点 (upper + lower) / 2 は平均復帰ターゲットとして機能します。
  2. 現在終了したローソク足が新たな安値 (Low <= LowerBand) を付け、オープンなポジションがない場合、この戦略は市場で購入します。保護ストップはエントリー価格の周囲に反映され、中間点が利益目標となり、MetaTrader の計算 sl = 2 * Ask - tp と一致します。
  3. ローソク足が新高値 (High >= UpperBand) を更新し、オープンなポジションがない場合、戦略は価格を上回る対称的なストップで市場で売ります。中間点は再び利益確定レベルとして機能します。
  4. ストップロスとテイクプロフィットは、完成したすべてのローソク足で監視されます。ストップを超えたブレイクアウトはポジションを即座に閉じますが、中間点に触れると意図したターゲットで取引を終了します。内部状態は、位置が平坦になるたびに自動的にリセットされます。

ポジションサイズ

  • 取引あたりのリスクは Portfolio.CurrentValue * (RiskPercent / 100) に相当します。ポートフォリオ データが利用できない場合、戦略は最小取引可能量に戻ります。
  • 契約リスクは |EntryPrice - StopPrice| として測定されます。生の音量は RiskAmount / perUnitRisk で、楽器の音量ステップに正規化されます。最小および最大の交換制約が尊重されます。正規化されたボリュームが取引可能な最小サイズより小さい場合、代わりに最小値が使用されます。

パラメーター

名前 説明 デフォルト
CandleType Donchian チャネルの構築に使用されるローソクの種類と時間枠。 15分の時間枠
LookbackPeriod 最高値と最低値を計算するために使用されるローソク足の数。 200
RiskPercent 取引ごとにリスクがかかるポートフォリオの株式の割合。 1%

すべてのパラメーターは、組み込みオプティマイザーによる最適化をサポートします。

追加メモ

  • この戦略は一度に 1 つのポジションのみを取引し、MQL バージョンの PositionsTotal()>0 ガードを複製します。
  • ストップロスとテイクプロフィットの価格は、個別の注文を送信するのではなく内部で維持されます。これにより、高レベルの API との互換性を維持しながら、ロジックを元のエキスパートアドバイザーに近づけることができます。
  • ポートフォリオの株式や商品の出来高に関する情報が欠落している場合でも、戦略は動作を決定論的に保つために可能な限り最小の出来高を使用して取引します。
using System;
using System.Linq;
using System.Collections.Generic;

using Ecng.Common;
using Ecng.Collections;
using Ecng.Serialization;

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

namespace StockSharp.Samples.Strategies;

/// <summary>
/// Port of the MetaTrader strategy MeanReversion.mq5.
/// Buys when price sets a fresh lookback low and targets the mid-point of the recent range,
/// or sells at a new high aiming for the same reversion level.
/// Position size is determined from the percentage risk and the stop distance.
/// </summary>
public class MeanReversionDonchianStrategy : Strategy
{
	private readonly StrategyParam<DataType> _candleType;
	private readonly StrategyParam<int> _lookbackPeriod;
	private readonly StrategyParam<decimal> _riskPercent;

	private DonchianChannels _donchian = null!;

	private decimal? _stopPrice;
	private decimal? _takeProfitPrice;
	private Sides? _activeSide;

	/// <summary>
	/// Candle type and timeframe used for the Donchian channel calculation.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	/// <summary>
	/// Amount of candles included in the high/low range.
	/// </summary>
	public int LookbackPeriod
	{
		get => _lookbackPeriod.Value;
		set => _lookbackPeriod.Value = value;
	}

	/// <summary>
	/// Percent of portfolio equity risked per trade.
	/// </summary>
	public decimal RiskPercent
	{
		get => _riskPercent.Value;
		set => _riskPercent.Value = value;
	}

	/// <summary>
	/// Initializes a new instance of the <see cref="MeanReversionDonchianStrategy"/>.
	/// </summary>
	public MeanReversionDonchianStrategy()
	{
		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(15).TimeFrame())
		.SetDisplay("Candle Type", "Type of candles to analyze", "General");

		_lookbackPeriod = Param(nameof(LookbackPeriod), 200)
		.SetDisplay("Lookback", "Number of candles used for range detection", "Signals")
		.SetRange(20, 500)
		;

		_riskPercent = Param(nameof(RiskPercent), 1m)
		.SetDisplay("Risk %", "Percentage of equity risked per entry", "Money Management")
		.SetRange(0.25m, 5m)
		;
	}

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

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

		_stopPrice = null;
		_takeProfitPrice = null;
		_activeSide = null;
	}

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

		_donchian = new DonchianChannels { Length = LookbackPeriod };

		var subscription = SubscribeCandles(CandleType);
		subscription
		.BindEx(_donchian, ProcessCandle)
		.Start();

		var area = CreateChartArea();
		if (area != null)
		{
			DrawCandles(area, subscription);
			DrawIndicator(area, _donchian);
			DrawOwnTrades(area);
		}
	}

	private void ProcessCandle(ICandleMessage candle, IIndicatorValue donchianValue)
	{
		if (candle.State != CandleStates.Finished)
		return;

		// indicators bound via BindEx

		ManageOpenPosition(candle);

		if (Position != 0)
		return;

		if (donchianValue is not IDonchianChannelsValue channel)
			return;

		if (channel.UpperBand is not decimal upperBand || channel.LowerBand is not decimal lowerBand || channel.Middle is not decimal midBand)
		return;

		GenerateSignals(candle, lowerBand, upperBand, midBand);
	}

	private void ManageOpenPosition(ICandleMessage candle)
	{
		if (Position > 0 && _activeSide == Sides.Buy)
		{
			if (_stopPrice.HasValue && candle.LowPrice <= _stopPrice.Value)
			{
				SellMarket(Position);
				ResetPositionState();
				return;
			}

			if (_takeProfitPrice.HasValue && candle.HighPrice >= _takeProfitPrice.Value)
			{
				SellMarket(Position);
				ResetPositionState();
			}
		}
		else if (Position < 0 && _activeSide == Sides.Sell)
		{
			if (_stopPrice.HasValue && candle.HighPrice >= _stopPrice.Value)
			{
				BuyMarket(-Position);
				ResetPositionState();
				return;
			}

			if (_takeProfitPrice.HasValue && candle.LowPrice <= _takeProfitPrice.Value)
			{
				BuyMarket(-Position);
				ResetPositionState();
			}
		}

		if (Position == 0 && _activeSide != null)
		{
			ResetPositionState();
		}
	}

	private void GenerateSignals(ICandleMessage candle, decimal lowerBand, decimal upperBand, decimal midBand)
	{
		var closePrice = candle.ClosePrice;

		if (candle.LowPrice <= lowerBand)
		{
			var stopPrice = 2m * closePrice - midBand;
			var volume = CalculateRiskAdjustedVolume(closePrice, stopPrice);
			if (volume > 0m && stopPrice < closePrice)
			{
				BuyMarket(volume);
				_stopPrice = stopPrice;
				_takeProfitPrice = midBand;
				_activeSide = Sides.Buy;
			}
		}
		else if (candle.HighPrice >= upperBand)
		{
			var stopPrice = 2m * closePrice - midBand;
			var volume = CalculateRiskAdjustedVolume(closePrice, stopPrice);
			if (volume > 0m && stopPrice > closePrice)
			{
				SellMarket(volume);
				_stopPrice = stopPrice;
				_takeProfitPrice = midBand;
				_activeSide = Sides.Sell;
			}
		}
	}

	private decimal CalculateRiskAdjustedVolume(decimal entryPrice, decimal stopPrice)
	{
		var perUnitRisk = Math.Abs(entryPrice - stopPrice);
		if (perUnitRisk <= 0m)
		return 0m;

		var portfolioValue = Portfolio?.CurrentValue ?? 0m;
		var riskBudget = portfolioValue > 0m ? portfolioValue * (RiskPercent / 100m) : 0m;

		if (riskBudget <= 0m)
		{
			return GetMinimalVolume();
		}

		var rawVolume = riskBudget / perUnitRisk;
		var normalized = NormalizeVolume(rawVolume);
		var minimal = GetMinimalVolume();

		if (normalized < minimal)
		normalized = minimal;

		return normalized;
	}

	private decimal NormalizeVolume(decimal volume)
	{
		if (volume <= 0m)
		return 0m;

		var step = Security?.VolumeStep ?? 0m;
		if (step <= 0m)
		return volume;

		var normalized = Math.Floor(volume / step) * step;

		var max = Security?.MaxVolume ?? 0m;
		if (max > 0m && normalized > max)
		normalized = max;

		return normalized;
	}

	private decimal GetMinimalVolume()
	{
		var min = Security?.MinVolume ?? 0m;
		if (min > 0m)
		return min;

		var step = Security?.VolumeStep ?? 0m;
		if (step > 0m)
		return step;

		return Volume > 0m ? Volume : 1m;
	}

	private void ResetPositionState()
	{
		_stopPrice = null;
		_takeProfitPrice = null;
		_activeSide = null;
	}
}