在 GitHub 上查看

Tunnel Method EMA 策略

概述

Tunnel Method EMA 策略基于原版 MetaTrader「Tunnel Method」专家顾问,并使用 StockSharp 的高级 API 实现。策略运行在 1 小时 K 线之上,比较以下三条基于收盘价的指数移动平均线(EMA):

  • 快速 EMA(12):跟踪最新动量变化。
  • 中速 EMA(144):构成“通道”中轨,用于确认做空信号。
  • 慢速 EMA(169):定义长周期趋势,用作做多过滤器。

策略在任意时刻仅持有单向仓位(多头或空头),并通过硬性止损、止盈以及移动止损来控制风险。

交易逻辑

多头入场

  1. 等待 K 线收盘(不在未完成的 K 线上决策)。
  2. 当快速 EMA(12)从下方上穿慢速 EMA(169)时触发信号。
  3. 若当前无持仓,则按配置手数提交市价买单。

空头入场

  1. 等待 K 线收盘。
  2. 当快速 EMA(12)从上方下穿中速 EMA(144)时触发信号。
  3. 若当前无持仓,则提交市价卖单。

仓位管理

  • 止损:价格相对开仓价逆向运行 StopLossPoints(按合约最小价格步长折算)时平仓。
  • 止盈:价格顺向运行 TakeProfitPoints 时获利了结。
  • 移动止损:当浮盈达到 TrailingTriggerPoints 后启动,并以 TrailingStopPoints 的距离跟随价格。多头跟踪自进场以来的最高价,空头跟踪最低价;一旦价格回落到移动止损位置即平仓。
  • 状态重置:每次出场后都会清除内部跟踪变量,避免影响下一笔交易。

默认参数

参数 默认值 说明
CandleType TimeSpan.FromHours(1).TimeFrame() 使用 1 小时 K 线计算 EMA。
FastLength 12 快速 EMA 周期。
MediumLength 144 中速 EMA 周期,用于空头确认。
SlowLength 169 慢速 EMA 周期,用于多头确认。
StopLossPoints 25 止损距离(以价格点计)。
TakeProfitPoints 230 止盈距离(以价格点计)。
TrailingStopPoints 35 移动止损距离。
TrailingTriggerPoints 20 启动移动止损所需的最小浮盈。

策略特性

  • 类型:趋势跟随型均线交叉策略。
  • 标的:适用于提供小时级行情并具备明确价格步长的品种。
  • 方向:可做多亦可做空,始终保持单向仓位。
  • 时间框架:默认 1 小时,可通过 CandleType 参数调整。
  • 风险控制:内置硬止损、止盈以及移动止损。
  • 数据需求:仅依赖 K 线收盘价,无需额外市场深度或成交数据。

补充说明

  • 所有指标均使用 StockSharp 内置 EMA,实现方式符合高阶 API 的使用规范。
  • 策略忽略未完成的 K 线,避免重复触发信号或使用部分数据。
  • 移动止损价格通过 ShrinkPrice 调整到有效的最小价格步长,确保挂单价格有效。
  • 默认参数保持与原始 MQL 版本一致,同时支持通过 StockSharp 的参数优化工具进行调优。
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>
/// Tunnel method strategy that trades EMA crossovers on hourly candles.
/// Long trades are opened when the fast EMA crosses above the slow EMA.
/// Short trades are opened when the fast EMA crosses below the medium EMA.
/// Includes fixed stop-loss, take-profit, and a trailing stop once profit reaches a trigger.
/// </summary>
public class TunnelMethodEmaStrategy : Strategy
{
	private readonly StrategyParam<DataType> _candleType;
	private readonly StrategyParam<int> _fastLength;
	private readonly StrategyParam<int> _mediumLength;
	private readonly StrategyParam<int> _slowLength;
	private readonly StrategyParam<decimal> _stopLossPoints;
	private readonly StrategyParam<decimal> _takeProfitPoints;
	private readonly StrategyParam<decimal> _trailingStopPoints;
	private readonly StrategyParam<decimal> _trailingTriggerPoints;

	private bool _hasPreviousValues;
	private decimal _previousFast;
	private decimal _previousMedium;
	private decimal _previousSlow;

	private decimal _pointValue;
	private decimal _stopLossDistance;
	private decimal _takeProfitDistance;
	private decimal _trailingStopDistance;
	private decimal _trailingTriggerDistance;

	private decimal? _entryPrice;
	private decimal _highestSinceEntry;
	private decimal _lowestSinceEntry;
	private decimal? _longTrailingStop;
	private decimal? _shortTrailingStop;

	/// <summary>
	/// Initializes a new instance of the <see cref="TunnelMethodEmaStrategy"/> class.
	/// </summary>
	public TunnelMethodEmaStrategy()
	{
		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(1).TimeFrame())
			.SetDisplay("Candle Type", "Timeframe used for EMA calculations", "General");

		_fastLength = Param(nameof(FastLength), 12)
			.SetGreaterThanZero()
			.SetDisplay("Fast EMA Length", "Period of the fast EMA", "Indicators")
			
			.SetOptimize(6, 30, 2);

		_mediumLength = Param(nameof(MediumLength), 144)
			.SetGreaterThanZero()
			.SetDisplay("Medium EMA Length", "Period of the medium EMA", "Indicators")
			
			.SetOptimize(72, 200, 8);

		_slowLength = Param(nameof(SlowLength), 169)
			.SetGreaterThanZero()
			.SetDisplay("Slow EMA Length", "Period of the slow EMA", "Indicators")
			
			.SetOptimize(120, 220, 5);

		_stopLossPoints = Param(nameof(StopLossPoints), 25m)
			.SetNotNegative()
			.SetDisplay("Stop Loss (points)", "Protective stop distance expressed in price points", "Risk")
			
			.SetOptimize(10m, 60m, 5m);

		_takeProfitPoints = Param(nameof(TakeProfitPoints), 230m)
			.SetNotNegative()
			.SetDisplay("Take Profit (points)", "Profit target distance expressed in price points", "Risk")
			
			.SetOptimize(100m, 400m, 20m);

		_trailingStopPoints = Param(nameof(TrailingStopPoints), 35m)
			.SetNotNegative()
			.SetDisplay("Trailing Stop (points)", "Distance maintained by the trailing stop", "Risk")
			
			.SetOptimize(10m, 80m, 5m);

		_trailingTriggerPoints = Param(nameof(TrailingTriggerPoints), 20m)
			.SetNotNegative()
			.SetDisplay("Trailing Trigger (points)", "Profit required before the trailing stop activates", "Risk")
			
			.SetOptimize(5m, 60m, 5m);
	}

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

	/// <summary>
	/// Fast EMA period length.
	/// </summary>
	public int FastLength
	{
		get => _fastLength.Value;
		set => _fastLength.Value = value;
	}

	/// <summary>
	/// Medium EMA period length.
	/// </summary>
	public int MediumLength
	{
		get => _mediumLength.Value;
		set => _mediumLength.Value = value;
	}

	/// <summary>
	/// Slow EMA period length.
	/// </summary>
	public int SlowLength
	{
		get => _slowLength.Value;
		set => _slowLength.Value = value;
	}

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

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

	/// <summary>
	/// Trailing stop distance in price points.
	/// </summary>
	public decimal TrailingStopPoints
	{
		get => _trailingStopPoints.Value;
		set => _trailingStopPoints.Value = value;
	}

	/// <summary>
	/// Trailing activation threshold in price points.
	/// </summary>
	public decimal TrailingTriggerPoints
	{
		get => _trailingTriggerPoints.Value;
		set => _trailingTriggerPoints.Value = value;
	}

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

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

		_hasPreviousValues = false;
		_previousFast = 0m;
		_previousMedium = 0m;
		_previousSlow = 0m;
		_pointValue = 0m;
		_stopLossDistance = 0m;
		_takeProfitDistance = 0m;
		_trailingStopDistance = 0m;
		_trailingTriggerDistance = 0m;

		_entryPrice = null;
		_highestSinceEntry = 0m;
		_lowestSinceEntry = 0m;
		_longTrailingStop = null;
		_shortTrailingStop = null;
	}

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

		_pointValue = GetPointValue();
		_stopLossDistance = StopLossPoints * _pointValue;
		_takeProfitDistance = TakeProfitPoints * _pointValue;
		_trailingStopDistance = TrailingStopPoints * _pointValue;
		_trailingTriggerDistance = TrailingTriggerPoints * _pointValue;

		var slowEma = new ExponentialMovingAverage { Length = SlowLength };
		var mediumEma = new ExponentialMovingAverage { Length = MediumLength };
		var fastEma = new ExponentialMovingAverage { Length = FastLength };

		var subscription = SubscribeCandles(CandleType);

		subscription
			.Bind(slowEma, mediumEma, fastEma, ProcessCandle)
			.Start();
	}

	private void ProcessCandle(ICandleMessage candle, decimal slowValue, decimal mediumValue, decimal fastValue)
	{
		if (candle.State != CandleStates.Finished)
			// Ignore unfinished candles to work on closed data.
			return;

		if (!_hasPreviousValues)
		{
			_previousSlow = slowValue;
			_previousMedium = mediumValue;
			_previousFast = fastValue;
			_hasPreviousValues = true;
			return;
		}

		UpdateRiskDistances();
		// Refresh risk distances if the price step changes during runtime.

		if (Position == 0)
		{
			ResetPositionState();
			// Clear trailing state while flat to prepare for the next trade.
		}
		else if (Position > 0)
		{
			ManageLongPosition(candle);
		}
		else
		{
			ManageShortPosition(candle);
		}

		if (Position == 0)
		{
			var shouldOpenLong = _previousFast < _previousSlow && fastValue > slowValue;
			var shouldOpenShort = _previousFast > _previousMedium && fastValue < mediumValue;

			if (shouldOpenLong && Position <= 0)
			{
				var volume = Volume + Math.Abs(Position);
				if (volume > 0)
				{
					_entryPrice = candle.ClosePrice;
					_highestSinceEntry = candle.HighPrice;
					_longTrailingStop = null;
					// Enter long with current volume when the fast EMA crosses above the slow EMA.
					BuyMarket();
				}
			}
			else if (shouldOpenShort && Position >= 0)
			{
				var volume = Volume + Math.Abs(Position);
				if (volume > 0)
				{
					_entryPrice = candle.ClosePrice;
					_lowestSinceEntry = candle.LowPrice;
					_shortTrailingStop = null;
					// Enter short with current volume when the fast EMA crosses below the medium EMA.
					SellMarket();
				}
			}
		}

		_previousSlow = slowValue;
		_previousMedium = mediumValue;
		_previousFast = fastValue;
	}

	private void ManageLongPosition(ICandleMessage candle)
	{
		if (_entryPrice is null)
			_entryPrice = candle.ClosePrice;

		_highestSinceEntry = Math.Max(_highestSinceEntry, candle.HighPrice);
		// Track the highest price reached since the long entry.

		if (_takeProfitDistance > 0m && candle.HighPrice >= _entryPrice.Value + _takeProfitDistance)
		{
			SellMarket();
			ResetPositionState();
			return;
		}

		if (_stopLossDistance > 0m && candle.LowPrice <= _entryPrice.Value - _stopLossDistance)
		{
			SellMarket();
			ResetPositionState();
			return;
		}

		if (_trailingStopDistance <= 0m || _trailingTriggerDistance <= 0m)
			return;

		if (_highestSinceEntry - _entryPrice.Value < _trailingTriggerDistance)
			return;

		var candidate = _highestSinceEntry - _trailingStopDistance;
		// Align the trailing stop with the instrument price step.
		candidate = ShrinkPrice(candidate);

		if (!_longTrailingStop.HasValue || candidate > _longTrailingStop.Value)
			_longTrailingStop = candidate;

		if (_longTrailingStop.HasValue && candle.LowPrice <= _longTrailingStop.Value)
			// Close the long position once price falls to the trailing stop.
		{
			SellMarket();
			ResetPositionState();
		}
	}

	private void ManageShortPosition(ICandleMessage candle)
	{
		if (_entryPrice is null)
			_entryPrice = candle.ClosePrice;

		_lowestSinceEntry = _lowestSinceEntry == 0m ? candle.LowPrice : Math.Min(_lowestSinceEntry, candle.LowPrice);
		// Track the lowest price reached since the short entry.

		if (_takeProfitDistance > 0m && candle.LowPrice <= _entryPrice.Value - _takeProfitDistance)
		{
			BuyMarket();
			ResetPositionState();
			return;
		}

		if (_stopLossDistance > 0m && candle.HighPrice >= _entryPrice.Value + _stopLossDistance)
		{
			BuyMarket();
			ResetPositionState();
			return;
		}

		if (_trailingStopDistance <= 0m || _trailingTriggerDistance <= 0m)
			return;

		if (_entryPrice.Value - _lowestSinceEntry < _trailingTriggerDistance)
			return;

		var candidate = _lowestSinceEntry + _trailingStopDistance;
		// Align the trailing stop with the instrument price step.
		candidate = ShrinkPrice(candidate);

		if (!_shortTrailingStop.HasValue || candidate < _shortTrailingStop.Value)
			_shortTrailingStop = candidate;

		if (_shortTrailingStop.HasValue && candle.HighPrice >= _shortTrailingStop.Value)
			// Close the short position once price rises to the trailing stop.
		{
			BuyMarket();
			ResetPositionState();
		}
	}

	private void ResetPositionState()
	{
		_entryPrice = null;
		_highestSinceEntry = 0m;
		_lowestSinceEntry = 0m;
		_longTrailingStop = null;
		_shortTrailingStop = null;
	}

	private void UpdateRiskDistances()
	{
		var newPointValue = GetPointValue();
		if (newPointValue <= 0m)
			return;

		if (_pointValue != newPointValue)
		{
			_pointValue = newPointValue;
			_stopLossDistance = StopLossPoints * _pointValue;
			_takeProfitDistance = TakeProfitPoints * _pointValue;
			_trailingStopDistance = TrailingStopPoints * _pointValue;
			_trailingTriggerDistance = TrailingTriggerPoints * _pointValue;
		}
	}

	private decimal GetPointValue()
	{
		var step = Security?.PriceStep;
		if (step is > 0m)
			return step.Value;

		return 1m;
	}

	private decimal ShrinkPrice(decimal price)
	{
		if (_pointValue > 0m)
			return Math.Round(price / _pointValue) * _pointValue;
		return price;
	}
}