在 GitHub 上查看

百分比通道系统策略

该策略是 MetaTrader 专家顾问 Exp_PercentageCrossoverChannel_System 的直接移植版。策略追踪价格与自定义“Percentage Crossover Channel”指标的互动,当蜡烛在突破后重新回到通道内部时做出反应。所有信号流程均按照 StockSharp 的高层 API 重新实现。

交易逻辑

  1. 指标构建

    • Percentage Crossover Channel 构建一条跟随价格的自适应中线,中线的偏离速度不会超过设定百分比 (Percent)。
    • 上轨与下轨在中线上方/下方按相同百分比绘制。
    • 每根收盘的蜡烛都会根据 Shift 根之前的通道位置被赋予颜色:
      • 颜色 3 / 4:收盘价位于上轨之上(分别表示阴/阳线)。
      • 颜色 0 / 1:收盘价位于下轨之下(分别表示阴/阳线)。
      • 颜色 2:收盘价位于通道内部。
  2. 入场与出场

    • 评估最近 SignalBar 根蜡烛及其前一根蜡烛,完全复刻 MQL 中的 CopyBuffer 调用。
    • 多头序列olderColor > 2):市场最近在通道上方收盘。如果最新蜡烛重新回到通道内(recentColor < 3),则:
      • 在启用 SellPositionsClose 时平掉所有空头仓位。
      • 在仓位为空且 BuyPositionsOpen 启用的情况下开多。
    • 空头序列olderColor < 2):市场最近在通道下方收盘。如果最新蜡烛回到通道内(recentColor > 1),则:
      • 在启用 BuyPositionsClose 时平掉所有多头仓位。
      • 在仓位为空且 SellPositionsOpen 启用的情况下开空。
    • 策略因此等待“突破 + 回踩”组合后顺势入场。
  3. 风险控制

    • 可选的止损与止盈以价格步长为单位设置,并基于蜡烛最高价/最低价触发。
    • 一旦保护性指令触发,策略立即离场,并在同一根蜡烛内忽略新的进场信号,模拟原始 EA 中经纪商侧止损优先执行的行为。

参数说明

参数 说明
Percent 通道宽度,单位为百分比,对应 MQL 指标参数。
Shift 用于比较突破的回溯蜡烛数量。
SignalBar 信号评估所使用的偏移量(以蜡烛数计),默认值 1 表示上一根蜡烛。
BuyPositionsOpen / SellPositionsOpen 是否允许开多/开空。
BuyPositionsClose / SellPositionsClose 是否允许在出现反向信号时强制平仓。
StopLoss 止损距离,以 Security.PriceStep 的倍数表示。0 表示不使用。
TakeProfit 止盈距离,同样以价格步长表示。0 表示不使用。
CandleType 使用的蜡烛类型(时间框架),默认对应四小时周期 PERIOD_H4

实现细节

  • 由于 StockSharp 没有自带 Percentage Crossover Channel 指标,算法在策略内部重写,包括中线递推、上下轨以及颜色判定,步骤与 MQL 代码一致。
  • 持仓管理遵循原始的 BuyPositionOpen / SellPositionOpen 等辅助函数:先平掉反向仓位,再尝试开新仓,并在存在反向持仓时跳过信号。
  • MQL 附件中的资金管理、Deviation 滑点参数以及不同保证金模式的手数计算未被移植。请通过 StockSharp 的常规属性或外部平台配置下单量。
  • 止损/止盈被解释为“价格步长”的倍数,对应 MetaTrader 中的“点数”。请确认所连接的标的提供有效的 PriceStep

使用建议

  • 若希望复制 MetaTrader 的表现,请在高质量的四小时数据上运行该策略;也可以调整 CandleType 用于日内交易测试。

  • 信号需要至少两根带有效颜色信息的已完成蜡烛,因此初始化时应确保历史数据不少于 Shift + SignalBar + 1 根。

  • Percent 对策略灵敏度影响显著:数值越小,通道越贴近价格、交易越频繁;数值越大,则仅关注强势突破。

  • 策略始终保持单仓结构,只会在多头、空头或空仓三种状态之间切换,进行组合风控时需考虑这一点。

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>
/// Percentage Crossover Channel breakout system translated from MQL.
/// </summary>
public class PercentageCrossoverChannelSystemStrategy : Strategy
{
	private readonly StrategyParam<decimal> _percent;
	private readonly StrategyParam<int> _shift;
	private readonly StrategyParam<int> _signalBar;
	private readonly StrategyParam<bool> _buyOpen;
	private readonly StrategyParam<bool> _sellOpen;
	private readonly StrategyParam<bool> _buyClose;
	private readonly StrategyParam<bool> _sellClose;
	private readonly StrategyParam<int> _stopLoss;
	private readonly StrategyParam<int> _takeProfit;
	private readonly StrategyParam<DataType> _candleType;

	private readonly List<int> _colorHistory = new();
	private readonly List<decimal> _upperHistory = new();
	private readonly List<decimal> _lowerHistory = new();

	private decimal _previousMiddle;
	private bool _hasMiddle;
	private decimal? _entryPrice;

	public decimal Percent
	{
		get => _percent.Value;
		set => _percent.Value = value;
	}

	public int Shift
	{
		get => _shift.Value;
		set => _shift.Value = value;
	}

	public int SignalBar
	{
		get => _signalBar.Value;
		set => _signalBar.Value = value;
	}

	public bool BuyPositionsOpen
	{
		get => _buyOpen.Value;
		set => _buyOpen.Value = value;
	}

	public bool SellPositionsOpen
	{
		get => _sellOpen.Value;
		set => _sellOpen.Value = value;
	}

	public bool BuyPositionsClose
	{
		get => _buyClose.Value;
		set => _buyClose.Value = value;
	}

	public bool SellPositionsClose
	{
		get => _sellClose.Value;
		set => _sellClose.Value = value;
	}

	public int StopLoss
	{
		get => _stopLoss.Value;
		set => _stopLoss.Value = value;
	}

	public int TakeProfit
	{
		get => _takeProfit.Value;
		set => _takeProfit.Value = value;
	}

	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	public PercentageCrossoverChannelSystemStrategy()
	{
		_percent = Param(nameof(Percent), 1.0m)
			.SetGreaterThanZero()
			.SetDisplay("Channel Percent", "Percentage width of the channel", "Indicator");

		_shift = Param(nameof(Shift), 1)
			.SetGreaterThanZero()
			.SetDisplay("Shift", "Number of bars used for crossover comparison", "Indicator");

		_signalBar = Param(nameof(SignalBar), 1)
			.SetGreaterThanZero()
			.SetDisplay("Signal Bar", "Bars back to evaluate indicator colors", "Trading Rules");

		_buyOpen = Param(nameof(BuyPositionsOpen), true)
			.SetDisplay("Enable Long Entries", "Allow long position openings", "Trading Rules");

		_sellOpen = Param(nameof(SellPositionsOpen), true)
			.SetDisplay("Enable Short Entries", "Allow short position openings", "Trading Rules");

		_buyClose = Param(nameof(BuyPositionsClose), true)
			.SetDisplay("Allow Long Exits", "Permit closing long trades on bearish signals", "Trading Rules");

		_sellClose = Param(nameof(SellPositionsClose), true)
			.SetDisplay("Allow Short Exits", "Permit closing short trades on bullish signals", "Trading Rules");

		_stopLoss = Param(nameof(StopLoss), 1000)
			.SetDisplay("Stop Loss (steps)", "Protective stop loss distance in price steps", "Risk Management");

		_takeProfit = Param(nameof(TakeProfit), 2000)
			.SetDisplay("Take Profit (steps)", "Target profit distance in price steps", "Risk Management");

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(1).TimeFrame())
			.SetDisplay("Candle Type", "Primary timeframe for analysis", "General");
	}

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

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

		_colorHistory.Clear();
		_upperHistory.Clear();
		_lowerHistory.Clear();
		_hasMiddle = false;
		_previousMiddle = 0m;
		_entryPrice = null;
	}

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

		var subscription = SubscribeCandles(CandleType);
		subscription.Bind(ProcessCandle).Start();
	}

	private void ProcessCandle(ICandleMessage candle)
	{
		// Ignore interim updates; we only react on closed candles.
		if (candle.State != CandleStates.Finished)
			return;

		// Evaluate protective orders before generating new signals.
		var stopTriggered = HandleRisk(candle);

		// Mirror the MQL signal logic using cached indicator colors.
		if (_colorHistory.Count > SignalBar)
		{
			// Equivalent to CopyBuffer(..., SignalBar, 2, ...) from the EA.
			var recentIndex = _colorHistory.Count - SignalBar;
			var olderIndex = recentIndex - 1;

			if (olderIndex >= 0)
			{
				var recentColor = _colorHistory[recentIndex];
				var olderColor = _colorHistory[olderIndex];

				var shouldCloseShort = SellPositionsClose && olderColor > 2;
				var shouldCloseLong = BuyPositionsClose && olderColor < 2;
				var shouldOpenBuy = BuyPositionsOpen && olderColor > 2 && recentColor < 3;
				var shouldOpenSell = SellPositionsOpen && olderColor < 2 && recentColor > 1;

				// Close existing positions according to the original toggles.
				if (shouldCloseLong && Position > 0)
				{
					SellMarket();
					_entryPrice = null;
				}

				if (shouldCloseShort && Position < 0)
				{
					BuyMarket();
					_entryPrice = null;
				}

				// Enter only when we are flat to match the EA behaviour.
				if (!stopTriggered && Position == 0)
				{
					if (shouldOpenBuy)
					{
						BuyMarket();
						_entryPrice = candle.ClosePrice;
					}
					else if (shouldOpenSell)
					{
						SellMarket();
						_entryPrice = candle.ClosePrice;
					}
				}
			}
		}

		// Update indicator state after trading decisions are made.
		var color = CalculateColor(candle);

		_colorHistory.Add(color);
		TrimHistory();
	}

	private bool HandleRisk(ICandleMessage candle)
	{
		// Exit early if there is no stored entry price.
		if (_entryPrice is null)
			return false;

		// Price step is required to translate MQL points into absolute prices.
		if (Security?.PriceStep is not decimal step || step <= 0)
			return false;

		var triggered = false;

		if (Position > 0)
		{
			// Long position risk checks.
			if (StopLoss > 0)
			{
				var stopLevel = _entryPrice.Value - StopLoss * step;
				if (candle.LowPrice <= stopLevel)
				{
					SellMarket();
					_entryPrice = null;
					triggered = true;
				}
			}

			if (!triggered && TakeProfit > 0)
			{
				var takeLevel = _entryPrice.Value + TakeProfit * step;
				if (candle.HighPrice >= takeLevel)
				{
					SellMarket();
					_entryPrice = null;
					triggered = true;
				}
			}
		}
		else if (Position < 0)
		{
			// Short position risk checks.
			if (StopLoss > 0)
			{
				var stopLevel = _entryPrice.Value + StopLoss * step;
				if (candle.HighPrice >= stopLevel)
				{
					BuyMarket();
					_entryPrice = null;
					triggered = true;
				}
			}

			if (!triggered && TakeProfit > 0)
			{
				var takeLevel = _entryPrice.Value - TakeProfit * step;
				if (candle.LowPrice <= takeLevel)
				{
					BuyMarket();
					_entryPrice = null;
					triggered = true;
				}
			}
		}

		// Reset cached entry price once we are flat.
		if (Position == 0)
			_entryPrice = null;

		return triggered;
	}

	private int CalculateColor(ICandleMessage candle)
	{
		// Recreate the Percentage Crossover Channel midline and colour logic.
		var percentFactor = Percent / 100m;
		var plusVar = 1m + percentFactor;
		var minusVar = 1m - percentFactor;
		var close = candle.ClosePrice;

		// Initialise the midline on the very first candle.
		if (!_hasMiddle)
		{
			_previousMiddle = close;
			_hasMiddle = true;
		}

		var middle = _previousMiddle;
		var lowerCandidate = close * minusVar;
		var upperCandidate = close * plusVar;

		// Adjust the midline exactly as in the original indicator.
		if (lowerCandidate > _previousMiddle)
		{
			middle = lowerCandidate;
		}
		else if (upperCandidate < _previousMiddle)
		{
			middle = upperCandidate;
		}

		var upper = middle + middle * percentFactor;
		var lower = middle - middle * percentFactor;

		_previousMiddle = middle;

		var color = 2;

		// Determine candle colour relative to past channel values.
		if (_upperHistory.Count >= Shift)
		{
			var referenceIndex = _upperHistory.Count - Shift;
			var referenceUpper = _upperHistory[referenceIndex];
			var referenceLower = _lowerHistory[referenceIndex];

			if (close > referenceUpper)
			{
				color = candle.OpenPrice <= close ? 4 : 3;
			}
			else if (close < referenceLower)
			{
				color = candle.OpenPrice > close ? 0 : 1;
			}
		}

		// Persist channel history for future signal checks.
		_upperHistory.Add(upper);
		_lowerHistory.Add(lower);

		return color;
	}

	private void TrimHistory()
	{
		// Keep only as much history as needed for Shift and SignalBar lookbacks.
		var maxCapacity = Math.Max(Shift + SignalBar + 5, 16);
		if (_colorHistory.Count <= maxCapacity)
			return;

		var removeCount = _colorHistory.Count - maxCapacity;
		for (var i = 0; i < removeCount; i++)
		{
			try
			{
				_colorHistory.RemoveAt(0);
				_upperHistory.RemoveAt(0);
				_lowerHistory.RemoveAt(0);
			}
			catch { break; }
		}
	}
}