在 GitHub 上查看

Chandel Exit 再入场策略

该策略将 MetaTrader 智能交易系统“Exp_ChandelExitSign_ReOpen”移植到 StockSharp 的高级 API。它利用 Chandelier Exit 指标捕捉趋势突破,并在趋势持续时自动加仓。信号基于可配置的高周期 K 线计算,同时通过基于 ATR 的止损和可选的止盈保护仓位。

核心思想是把 Chandelier Exit 既当作趋势过滤器,又当作动态的追踪边界。当下轨向上穿越上轨时判定为多头信号,反向穿越则给出空头信号。多空逻辑完全对称,并且每类信号都可以通过参数单独启用或关闭。开仓后,只有当价格朝有利方向移动至少 PriceStepPoints 个最小价位时,系统才允许再次加仓,且总加仓次数受 MaxAdditions 限制,避免仓位无限膨胀。

交易逻辑

  • 信号计算
    • RangePeriod(配合 Shift 偏移)决定 Chandelier Exit 所使用的最高价和最低价窗口。
    • AtrPeriodAtrMultiplier 共同生成波动缓冲区,将退出带从价格中移开。
    • SignalBar(默认 1)让策略在上一根完整 K 线上执行,复现 MT5 的延迟逻辑。
  • 入场条件
    • 做多:当下轨穿越上轨(IsUpSignal)且 EnableBuyEntries = true 时触发。若已有空头持仓且 EnableSellExits = true,策略先尝试平掉空单。
    • 做空:当上轨穿越下轨(IsDownSignal)且 EnableSellEntries = true 时触发。若有多头持仓,只在 EnableBuyExits = true 时才会先行平仓。
  • 出场条件
    • 多单:在 EnableBuyExits = true 且出现空头信号时全部平仓,或当止损/止盈被击中时平仓。
    • 空单:在 EnableSellExits = true 且出现多头信号时全部平仓,或当保护位触发时平仓。
    • 当多空同时允许开平仓时,策略会回溯更早的指标值,确保即使当前 K 线只产生入场,也能找到合适的出场信号。
  • 加仓规则
    • 每次成交后都会记录最近的成交价。只有当价格朝有利方向移动不少于 PriceStepPoints * PriceStep 时,才会按 Volume 加仓一次,最多执行 MaxAdditions 次。
    • 每次加仓都会重新计算止损/止盈,使保护价格紧跟最新仓位。
  • 风险控制
    • StopLossPointsTakeProfitPoints 以最小价位为单位设置止损和止盈距离,填写 0 即可关闭相应功能。
    • 每根完成的 K 线都会检查保护条件,若价格在柱内触发保护位,则立即以市价平仓。

默认参数

参数 默认值 说明
CandleType TimeSpan.FromHours(4).TimeFrame() 计算指标时使用的时间周期。
RangePeriod 15 计算最高/最低价时的窗口长度。
Shift 1 在计算窗口之前跳过的最近 K 线数量。
AtrPeriod 14 ATR 的周期。
AtrMultiplier 4 ATR 乘数。
SignalBar 1 信号回溯的完成 K 线数量。
PriceStepPoints 300 允许加仓前价格需移动的最小价位数量。
MaxAdditions 10 首次开仓后允许的最大加仓次数。
StopLossPoints 1000 止损距离(以最小价位表示)。
TakeProfitPoints 2000 止盈距离(以最小价位表示)。
EnableBuyEntries / EnableSellEntries true 是否开启多头/空头入场。
EnableBuyExits / EnableSellExits true 是否允许多头/空头出场。

使用建议

  • Volume 决定基础下单手数,加仓同样使用该手数。若想降低风险,可调小 Volume 或减少 MaxAdditions
  • 由于阈值按最小价位定义,请确保合约的 PriceStep 信息正确,否则距离计算会失真。
  • SignalBar 设为 1 可以避免在产生信号的同一根 K 线上立即成交;若希望更激进,可以设置为 0。
  • 策略适用于可做多做空的市场。如果只想交易单边,可通过参数禁用另一方向。
  • 当图表区域可用时,策略会自动调用 DrawCandlesDrawIndicatorDrawOwnTrades,便于观察指标与成交情况。

示例流程

  1. 观察到下轨向上穿越上轨,出现多头信号。
  2. 若无持仓且允许做多,则按 Volume 下市价买单,同时根据成交价设置止损和止盈。
  3. 当价格向上运行不少于 PriceStepPoints * PriceStep 时,在不超出 MaxAdditions 的前提下再次买入加仓。
  4. 出现反向信号、触及止损或止盈时平掉全部多单;空头流程与之相反。

该说明在保持 MT5 原始逻辑的同时,遵循 StockSharp 的常见约定:通过策略参数管理配置、使用高级 K 线订阅以及显式的仓位管理。

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>
/// Strategy converted from the ChandelExitSign expert advisor with re-entry logic.
/// </summary>
public class ChandelExitReopenStrategy : Strategy
{
	private readonly StrategyParam<DataType> _candleType;
	private readonly StrategyParam<int> _rangePeriod;
	private readonly StrategyParam<int> _shift;
	private readonly StrategyParam<int> _atrPeriod;
	private readonly StrategyParam<decimal> _atrMultiplier;
	private readonly StrategyParam<int> _signalBar;
	private readonly StrategyParam<decimal> _priceStepPoints;
	private readonly StrategyParam<int> _maxAdditions;
	private readonly StrategyParam<int> _stopLossPoints;
	private readonly StrategyParam<int> _takeProfitPoints;
	private readonly StrategyParam<bool> _enableBuyEntries;
	private readonly StrategyParam<bool> _enableSellEntries;
	private readonly StrategyParam<bool> _enableBuyExits;
	private readonly StrategyParam<bool> _enableSellExits;

	private readonly List<CandleInfo> _history = new();
	private readonly List<SignalInfo> _signals = new();

	private decimal? _previousUp;
	private decimal? _previousDown;
	private int _direction;

	private int _longAdditions;
	private int _shortAdditions;
	private decimal? _longEntryPrice;
	private decimal? _shortEntryPrice;
	private decimal? _longStopPrice;
	private decimal? _shortStopPrice;
	private decimal? _longTakePrice;
	private decimal? _shortTakePrice;
	private DateTimeOffset? _lastLongAdditionTime;
	private DateTimeOffset? _lastShortAdditionTime;

	/// <summary>
	/// Initializes a new instance of <see cref="ChandelExitReopenStrategy"/>.
	/// </summary>
	public ChandelExitReopenStrategy()
	{
		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
			.SetDisplay("Candle Type", "Type of candles used for signals", "General");

		_rangePeriod = Param(nameof(RangePeriod), 15)
			.SetDisplay("Range Period", "Lookback for highest high and lowest low", "Indicator")
			.SetGreaterThanZero()
			;

		_shift = Param(nameof(Shift), 1)
			.SetDisplay("Shift", "Bars to skip from the most recent data", "Indicator")
			.SetNotNegative()
			;

		_atrPeriod = Param(nameof(AtrPeriod), 14)
			.SetDisplay("ATR Period", "ATR length for volatility filter", "Indicator")
			.SetGreaterThanZero()
			;

		_atrMultiplier = Param(nameof(AtrMultiplier), 4m)
			.SetDisplay("ATR Multiplier", "Multiplier applied to ATR", "Indicator")
			.SetGreaterThanZero()
			;

		_signalBar = Param(nameof(SignalBar), 1)
			.SetDisplay("Signal Bar", "How many bars back to read signals", "Trading")
			.SetNotNegative();

		_priceStepPoints = Param(nameof(PriceStepPoints), 1000m)
			.SetDisplay("Re-entry Distance", "Minimum favorable move in price steps before adding", "Position Management")
			.SetNotNegative()
			;

		_maxAdditions = Param(nameof(MaxAdditions), 1)
			.SetDisplay("Max Additions", "Maximum number of re-entries after the initial position", "Position Management")
			.SetNotNegative();

		_stopLossPoints = Param(nameof(StopLossPoints), 1000)
			.SetDisplay("Stop Loss Points", "Stop-loss distance in price steps", "Risk Management")
			.SetNotNegative();

		_takeProfitPoints = Param(nameof(TakeProfitPoints), 2000)
			.SetDisplay("Take Profit Points", "Take-profit distance in price steps", "Risk Management")
			.SetNotNegative();

		_enableBuyEntries = Param(nameof(EnableBuyEntries), true)
			.SetDisplay("Enable Long Entries", "Allow opening long positions on up signals", "Trading");

		_enableSellEntries = Param(nameof(EnableSellEntries), true)
			.SetDisplay("Enable Short Entries", "Allow opening short positions on down signals", "Trading");

		_enableBuyExits = Param(nameof(EnableBuyExits), true)
			.SetDisplay("Enable Long Exits", "Allow closing long positions on down signals", "Trading");

		_enableSellExits = Param(nameof(EnableSellExits), true)
			.SetDisplay("Enable Short Exits", "Allow closing short positions on up signals", "Trading");
	}

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

	/// <summary>
	/// Range length for the Chandelier exit bands.
	/// </summary>
	public int RangePeriod
	{
		get => _rangePeriod.Value;
		set => _rangePeriod.Value = value;
	}

	/// <summary>
	/// Number of the most recent bars skipped before measuring the range.
	/// </summary>
	public int Shift
	{
		get => _shift.Value;
		set => _shift.Value = value;
	}

	/// <summary>
	/// ATR length used in the signal calculation.
	/// </summary>
	public int AtrPeriod
	{
		get => _atrPeriod.Value;
		set => _atrPeriod.Value = value;
	}

	/// <summary>
	/// Multiplier applied to the ATR value.
	/// </summary>
	public decimal AtrMultiplier
	{
		get => _atrMultiplier.Value;
		set => _atrMultiplier.Value = value;
	}

	/// <summary>
	/// Offset of the signal bar relative to the latest finished candle.
	/// </summary>
	public int SignalBar
	{
		get => _signalBar.Value;
		set => _signalBar.Value = value;
	}

	/// <summary>
	/// Required move in price steps before another position add is allowed.
	/// </summary>
	public decimal PriceStepPoints
	{
		get => _priceStepPoints.Value;
		set => _priceStepPoints.Value = value;
	}

	/// <summary>
	/// Maximum number of additional entries after the first fill.
	/// </summary>
	public int MaxAdditions
	{
		get => _maxAdditions.Value;
		set => _maxAdditions.Value = value;
	}

	/// <summary>
	/// Stop-loss distance expressed in price steps.
	/// </summary>
	public int StopLossPoints
	{
		get => _stopLossPoints.Value;
		set => _stopLossPoints.Value = value;
	}

	/// <summary>
	/// Take-profit distance expressed in price steps.
	/// </summary>
	public int TakeProfitPoints
	{
		get => _takeProfitPoints.Value;
		set => _takeProfitPoints.Value = value;
	}

	/// <summary>
	/// Enables long entries generated by the up buffer.
	/// </summary>
	public bool EnableBuyEntries
	{
		get => _enableBuyEntries.Value;
		set => _enableBuyEntries.Value = value;
	}

	/// <summary>
	/// Enables short entries generated by the down buffer.
	/// </summary>
	public bool EnableSellEntries
	{
		get => _enableSellEntries.Value;
		set => _enableSellEntries.Value = value;
	}

	/// <summary>
	/// Enables long exits on down signals.
	/// </summary>
	public bool EnableBuyExits
	{
		get => _enableBuyExits.Value;
		set => _enableBuyExits.Value = value;
	}

	/// <summary>
	/// Enables short exits on up signals.
	/// </summary>
	public bool EnableSellExits
	{
		get => _enableSellExits.Value;
		set => _enableSellExits.Value = value;
	}

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

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

		_history.Clear();
		_signals.Clear();

		_previousUp = null;
		_previousDown = null;
		_direction = 0;

		ResetLongState();
		ResetShortState();
	}

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

		var atr = new AverageTrueRange { Length = AtrPeriod };
		var subscription = SubscribeCandles(CandleType);

		subscription
			.BindEx(atr, ProcessCandle)
			.Start();

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

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

		var atr = atrValue.IsFinal ? atrValue.ToDecimal() : 0m;
		var info = new CandleInfo(candle.OpenTime, candle.HighPrice, candle.LowPrice, candle.ClosePrice, atr);

		_history.Add(info);

		SignalInfo signal;
		if (atrValue.IsFinal)
		{
			signal = CalculateSignal(info);
		}
		else
		{
			signal = SignalInfo.Empty(info.Time);
		}

		_signals.Add(signal);
		TrimCache();

		if (!atrValue.IsFinal)
			return;

		if (_signals.Count <= SignalBar)
			return;

		var signals = _signals.ToArray();
		var targetIndex = signals.Length - 1 - SignalBar;
		if (targetIndex < 0)
			return;

		var targetSignal = signals[targetIndex];
		if (targetSignal is null)
			return;

		var buyOpen = targetSignal.IsUpSignal && EnableBuyEntries;
		var sellOpen = targetSignal.IsDownSignal && EnableSellEntries;
		var buyClose = targetSignal.IsDownSignal && EnableBuyExits;
		var sellClose = targetSignal.IsUpSignal && EnableSellExits;

		if (((EnableBuyEntries && EnableBuyExits) || (EnableSellEntries && EnableSellExits)) && !buyClose && !sellClose)
		{
			for (var idx = targetIndex - 1; idx >= 0; idx--)
			{
				var previousSignal = signals[idx];
				if (previousSignal is null)
					continue;

				if (!sellClose && EnableSellExits && previousSignal.IsUpSignal)
				{
					sellClose = true;
					break;
				}

				if (!buyClose && EnableBuyExits && previousSignal.IsDownSignal)
				{
					buyClose = true;
					break;
				}
			}
		}

		var step = Security.PriceStep ?? 1m;
		var priceStep = PriceStepPoints * step;

		var longClosed = false;
		var shortClosed = false;

		if (Position > 0m)
		{
			if (_longStopPrice is decimal sl && candle.LowPrice <= sl)
			{
				SellMarket();
				ResetLongState();
				longClosed = true;
				this.LogInfo($"Long stop triggered at {sl:0.########}");
			}
			else if (_longTakePrice is decimal tp && candle.HighPrice >= tp)
			{
				SellMarket();
				ResetLongState();
				longClosed = true;
				this.LogInfo($"Long take profit triggered at {tp:0.########}");
			}
		}

		if (Position < 0m)
		{
			if (_shortStopPrice is decimal sl && candle.HighPrice >= sl)
			{
				BuyMarket();
				ResetShortState();
				shortClosed = true;
				this.LogInfo($"Short stop triggered at {sl:0.########}");
			}
			else if (_shortTakePrice is decimal tp && candle.LowPrice <= tp)
			{
				BuyMarket();
				ResetShortState();
				shortClosed = true;
				this.LogInfo($"Short take profit triggered at {tp:0.########}");
			}
		}

		if (!longClosed && buyClose && Position > 0m)
		{
			SellMarket();
			ResetLongState();
			longClosed = true;
			this.LogInfo($"Long exit on down signal at {candle.ClosePrice:0.########}");
		}

		if (!shortClosed && sellClose && Position < 0m)
		{
			BuyMarket();
			ResetShortState();
			shortClosed = true;
			this.LogInfo($"Short exit on up signal at {candle.ClosePrice:0.########}");
		}

		if (!longClosed && Position > 0m && MaxAdditions > 0 && _longEntryPrice is decimal lastLongPrice && priceStep > 0m && _longAdditions < MaxAdditions)
		{
			if (candle.ClosePrice - lastLongPrice >= priceStep && _lastLongAdditionTime != candle.OpenTime)
			{
				if (Volume > 0m)
				{
					BuyMarket();
					_longAdditions++;
					_longEntryPrice = candle.ClosePrice;
					_lastLongAdditionTime = candle.OpenTime;
					UpdateLongProtection(candle.ClosePrice, step);
					this.LogInfo($"Added to long position at {candle.ClosePrice:0.########} (add #{_longAdditions})");
				}
			}
		}

		if (!shortClosed && Position < 0m && MaxAdditions > 0 && _shortEntryPrice is decimal lastShortPrice && priceStep > 0m && _shortAdditions < MaxAdditions)
		{
			if (lastShortPrice - candle.ClosePrice >= priceStep && _lastShortAdditionTime != candle.OpenTime)
			{
				if (Volume > 0m)
				{
					SellMarket();
					_shortAdditions++;
					_shortEntryPrice = candle.ClosePrice;
					_lastShortAdditionTime = candle.OpenTime;
					UpdateShortProtection(candle.ClosePrice, step);
					this.LogInfo($"Added to short position at {candle.ClosePrice:0.########} (add #{_shortAdditions})");
				}
			}
		}

		if (buyOpen && Position < 0m && !EnableSellExits)
		buyOpen = false;

		if (sellOpen && Position > 0m && !EnableBuyExits)
		sellOpen = false;

		if (buyOpen && Volume > 0m)
		{
			BuyMarket();
			ResetShortState();
			_longAdditions = 0;
			_longEntryPrice = candle.ClosePrice;
			_lastLongAdditionTime = candle.OpenTime;
			UpdateLongProtection(candle.ClosePrice, step);
			this.LogInfo($"Opened long position at {candle.ClosePrice:0.########}");
		}

		if (sellOpen && Volume > 0m)
		{
			SellMarket();
			ResetLongState();
			_shortAdditions = 0;
			_shortEntryPrice = candle.ClosePrice;
			_lastShortAdditionTime = candle.OpenTime;
			UpdateShortProtection(candle.ClosePrice, step);
			this.LogInfo($"Opened short position at {candle.ClosePrice:0.########}");
		}
	}

	private void TrimCache()
	{
		var maxItems = Math.Max(RangePeriod + Shift + 5, SignalBar + 5) + 50;
		if (_history.Count <= maxItems)
			return;

		var removeCount = _history.Count - maxItems;
		_history.RemoveRange(0, removeCount);
		_signals.RemoveRange(0, removeCount);
	}

	private SignalInfo CalculateSignal(CandleInfo current)
	{
		var history = _history.ToArray();
		var currentIndex = history.Length - 1;
		var range = RangePeriod;
		var shift = Shift;

		if (range <= 0 || currentIndex - shift < 0)
		return SignalInfo.Empty(current.Time);

		var windowEnd = currentIndex - shift;
		var windowStart = windowEnd - (range - 1);

		if (windowStart < 0 || windowEnd >= history.Length)
		return SignalInfo.Empty(current.Time);

		var highestHigh = decimal.MinValue;
		var lowestLow = decimal.MaxValue;

		for (var i = windowStart; i <= windowEnd; i++)
		{
			var item = history[i];
			if (item is null)
				continue;

			if (item.High > highestHigh)
			highestHigh = item.High;
			if (item.Low < lowestLow)
			lowestLow = item.Low;
		}

		if (highestHigh == decimal.MinValue || lowestLow == decimal.MaxValue)
			return SignalInfo.Empty(current.Time);

		var atr = current.Atr * AtrMultiplier;
		var upperBand = highestHigh - atr;
		var lowerBand = lowestLow + atr;

		decimal up;
		decimal down;

		if (_direction >= 0)
		{
			if (current.Close < upperBand)
			{
				_direction = -1;
				up = lowerBand;
				down = upperBand;
			}
			else
			{
				up = upperBand;
				down = lowerBand;
			}
		}
		else
		{
			if (current.Close > lowerBand)
			{
				_direction = 1;
				down = lowerBand;
				up = upperBand;
			}
			else
			{
				up = lowerBand;
				down = upperBand;
			}
		}

		var isUpSignal = false;
		var isDownSignal = false;

		if (_previousDown is decimal prevDn && _previousUp is decimal prevUp)
		{
			if (prevDn <= prevUp && down > up)
			isUpSignal = true;

			if (prevDn >= prevUp && down < up)
			isDownSignal = true;
		}

		_previousUp = up;
		_previousDown = down;

		return new SignalInfo(current.Time, isUpSignal, isDownSignal, up, down);
	}

	private void UpdateLongProtection(decimal entryPrice, decimal step)
	{
		_longStopPrice = StopLossPoints > 0 ? entryPrice - StopLossPoints * step : null;
		_longTakePrice = TakeProfitPoints > 0 ? entryPrice + TakeProfitPoints * step : null;
	}

	private void UpdateShortProtection(decimal entryPrice, decimal step)
	{
		_shortStopPrice = StopLossPoints > 0 ? entryPrice + StopLossPoints * step : null;
		_shortTakePrice = TakeProfitPoints > 0 ? entryPrice - TakeProfitPoints * step : null;
	}

	private void ResetLongState()
	{
		_longAdditions = 0;
		_longEntryPrice = null;
		_longStopPrice = null;
		_longTakePrice = null;
		_lastLongAdditionTime = null;
	}

	private void ResetShortState()
	{
		_shortAdditions = 0;
		_shortEntryPrice = null;
		_shortStopPrice = null;
		_shortTakePrice = null;
		_lastShortAdditionTime = null;
	}

	private sealed record CandleInfo(DateTimeOffset Time, decimal High, decimal Low, decimal Close, decimal Atr);

	private sealed record SignalInfo(DateTimeOffset Time, bool IsUpSignal, bool IsDownSignal, decimal Up, decimal Down)
	{
		public static SignalInfo Empty(DateTimeOffset time) => new(time, false, false, 0m, 0m);
	}
}