在 GitHub 上查看

TTM 趋势再入场策略

概述

该策略复刻了 MetaTrader 智能交易系统 Exp_ttm-trend_ReOpen 的核心逻辑。它在 StockSharp 框架中重建 TTM Trend 指标,使用 Heikin-Ashi 平滑来给蜡烛着色,并在颜色从看跌翻转为看涨或反向时执行交易。每一次颜色改变都代表波动压缩或扩张状态的切换,策略会立即平掉反向持仓并按新的方向入场。

指标机制

原始指标根据 Heikin-Ashi 实体与传统 K 线的组合情况给蜡烛着色:

  • 亮绿色 (4) – Heikin-Ashi 收盘高于开盘,且标准 K 线收阳。
  • 青绿色 (3) – Heikin-Ashi 看涨,但标准 K 线收阴。
  • 洋红色 (0) – Heikin-Ashi 看跌,且标准 K 线收阴。
  • 紫色 (1) – Heikin-Ashi 看跌,而标准 K 线收阳。
  • 灰色 (2) – 无法判断趋势时的默认颜色。

为了模拟 MetaTrader 中的平滑逻辑,指标会维护一个 CompBars 长度的 Heikin-Ashi 历史窗口。如果最新实体完全落在任意历史实体的高低范围内,则沿用历史颜色,从而过滤掉细小回撤所产生的噪声,这与原始实现保持一致。

交易规则

  1. 订阅 CandleType 指定的周期,只评估已经收盘的蜡烛;SignalBar 用于指定相对于最新历史点回看多少根已收盘蜡烛。
  2. 当出现 看涨颜色(值为 1 或 4)且上一信号并非看涨时:
    • 若启用 EnableShortExits,先平掉空头仓位。
    • 若启用 EnableLongEntries,开多或从空头翻多。
  3. 当出现 看跌颜色(值为 0 或 3)且上一信号并非看跌时:
    • 若启用 EnableLongExits,先平掉多头仓位。
    • 若启用 EnableShortEntries,开空或从多头翻空。
  4. 当浮盈达到 PriceStepPoints(根据标的 PriceStep 转换为价格)时,策略可以按当前方向继续加仓。每个方向的累计入场次数由 MaxPositions 限制。

加仓逻辑

  • PriceStepPoints 对应原版 EA 的“PriceStep”参数:当未实现利润超过这一距离时,再加一笔基础 Volume
  • MaxPositions 定义每个方向最多允许的仓位次数(包含首笔)。若设为 1,即完全关闭再入场功能。

风险控制

StopLossPointsTakeProfitPoints 以标的点值表示,与原 EA 一致。策略会根据 Security.PriceStep 将其换算为绝对价格距离,并通过 StartProtection 自动挂出止损/止盈。将任一参数设为 0 即可禁用对应保护。

参数说明

  • CandleType – 计算 TTM Trend 时使用的时间周期(默认 4 小时)。
  • CompBars – 用于平滑颜色的 Heikin-Ashi 历史长度(默认 6)。
  • SignalBar – 相对最新完成蜡烛回看多少根做决策(默认 1,即最近一根收盘蜡烛)。
  • PriceStepPoints – 触发加仓所需的最小盈利点数(默认 300)。
  • MaxPositions – 每个方向的累计开仓上限(默认 10)。
  • EnableLongEntries / EnableShortEntries – 控制颜色翻转时是否开多/开空。
  • EnableLongExits / EnableShortExits – 控制出现反向颜色时是否强制平仓。
  • StopLossPoints – 止损距离(默认 1000 点)。
  • TakeProfitPoints – 止盈距离(默认 2000 点)。

使用建议

  • TTM Trend 对时间周期较敏感;原系统使用 H4 图表,但本策略可接入任何 CandleType
  • 指标基于 Heikin-Ashi 实体,跳空行情可能需要下一根蜡烛确认颜色翻转。
  • 若不希望加仓,将 PriceStepPoints 设为 0 即可实现单次入场模式。
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>
/// TTM Trend strategy with re-entry logic inspired by the MetaTrader expert.
/// Opens positions when the TTM Trend color flips and pyramids after price moves far enough.
/// </summary>
public class TtmTrendReopenStrategy : Strategy
{
	private readonly StrategyParam<DataType> _candleType;
	private readonly StrategyParam<int> _compBars;
	private readonly StrategyParam<int> _signalBar;
	private readonly StrategyParam<decimal> _priceStepPoints;
	private readonly StrategyParam<int> _maxPositions;
	private readonly StrategyParam<bool> _enableLongEntries;
	private readonly StrategyParam<bool> _enableShortEntries;
	private readonly StrategyParam<bool> _enableLongExits;
	private readonly StrategyParam<bool> _enableShortExits;
	private readonly StrategyParam<decimal> _stopLossPoints;
	private readonly StrategyParam<decimal> _takeProfitPoints;

	private TtmTrendIndicator _ttmIndicator;
	private readonly List<int> _colorHistory = new();
	private int _longEntries;
	private int _shortEntries;
	private decimal _entryPrice;

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

	/// <summary>
	/// Number of Heikin-Ashi comparison bars maintained by the indicator.
	/// </summary>
	public int CompBars
	{
		get => _compBars.Value;
		set => _compBars.Value = Math.Max(1, value);
	}

	/// <summary>
	/// Offset of the bar used for signal detection (0 = latest closed candle).
	/// </summary>
	public int SignalBar
	{
		get => _signalBar.Value;
		set => _signalBar.Value = Math.Max(0, value);
	}

	/// <summary>
	/// Minimum favorable move in points before adding to an existing position.
	/// </summary>
	public decimal PriceStepPoints
	{
		get => _priceStepPoints.Value;
		set => _priceStepPoints.Value = Math.Max(0m, value);
	}

	/// <summary>
	/// Maximum number of entries per direction (including the first one).
	/// </summary>
	public int MaxPositions
	{
		get => _maxPositions.Value;
		set => _maxPositions.Value = Math.Max(1, value);
	}

	/// <summary>
	/// Allow opening new long positions on bullish colors.
	/// </summary>
	public bool EnableLongEntries
	{
		get => _enableLongEntries.Value;
		set => _enableLongEntries.Value = value;
	}

	/// <summary>
	/// Allow opening new short positions on bearish colors.
	/// </summary>
	public bool EnableShortEntries
	{
		get => _enableShortEntries.Value;
		set => _enableShortEntries.Value = value;
	}

	/// <summary>
	/// Allow closing long positions when a bearish color appears.
	/// </summary>
	public bool EnableLongExits
	{
		get => _enableLongExits.Value;
		set => _enableLongExits.Value = value;
	}

	/// <summary>
	/// Allow closing short positions when a bullish color appears.
	/// </summary>
	public bool EnableShortExits
	{
		get => _enableShortExits.Value;
		set => _enableShortExits.Value = value;
	}

	/// <summary>
	/// Stop-loss distance expressed in instrument points.
	/// </summary>
	public decimal StopLossPoints
	{
		get => _stopLossPoints.Value;
		set => _stopLossPoints.Value = Math.Max(0m, value);
	}

	/// <summary>
	/// Take-profit distance expressed in instrument points.
	/// </summary>
	public decimal TakeProfitPoints
	{
		get => _takeProfitPoints.Value;
		set => _takeProfitPoints.Value = Math.Max(0m, value);
	}

	/// <summary>
	/// Initializes a new instance of the <see cref="TtmTrendReopenStrategy"/>.
	/// </summary>
	public TtmTrendReopenStrategy()
	{
		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
		.SetDisplay("Candle Type", "Timeframe for the TTM Trend calculation", "General");

		_compBars = Param(nameof(CompBars), 6)
		.SetGreaterThanZero()
		.SetDisplay("Comparison Bars", "Heikin-Ashi bars stored for the color smoothing", "Indicator")
		
		.SetOptimize(3, 12, 1);

		_signalBar = Param(nameof(SignalBar), 1)
		.SetNotNegative()
		.SetDisplay("Signal Bar", "Offset of the bar used for trading decisions", "Indicator")
		
		.SetOptimize(0, 3, 1);

		_priceStepPoints = Param(nameof(PriceStepPoints), 1000m)
		.SetNotNegative()
		.SetDisplay("Re-entry Step", "Minimum favorable move (in points) before pyramiding", "Risk Management")
		
		.SetOptimize(100m, 600m, 100m);

		_maxPositions = Param(nameof(MaxPositions), 1)
		.SetGreaterThanZero()
		.SetDisplay("Max Entries", "Maximum number of stacked entries per direction", "Risk Management")
		
		.SetOptimize(1, 10, 1);

		_enableLongEntries = Param(nameof(EnableLongEntries), true)
		.SetDisplay("Enable Long Entries", "Allow buying when the TTM Trend turns bullish", "Trading Rules");

		_enableShortEntries = Param(nameof(EnableShortEntries), true)
		.SetDisplay("Enable Short Entries", "Allow selling when the TTM Trend turns bearish", "Trading Rules");

		_enableLongExits = Param(nameof(EnableLongExits), true)
		.SetDisplay("Enable Long Exits", "Close longs on bearish colors", "Trading Rules");

		_enableShortExits = Param(nameof(EnableShortExits), true)
		.SetDisplay("Enable Short Exits", "Close shorts on bullish colors", "Trading Rules");

		_stopLossPoints = Param(nameof(StopLossPoints), 1000m)
		.SetNotNegative()
		.SetDisplay("Stop Loss (points)", "Protective stop distance in price points", "Risk Management")
		
		.SetOptimize(200m, 2000m, 200m);

		_takeProfitPoints = Param(nameof(TakeProfitPoints), 2000m)
		.SetNotNegative()
		.SetDisplay("Take Profit (points)", "Profit target distance in price points", "Risk Management")
		
		.SetOptimize(500m, 4000m, 500m);
	}

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

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

		_colorHistory.Clear();
		_longEntries = 0;
		_shortEntries = 0;
		_entryPrice = 0m;
		_ttmIndicator = null!;
	}

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

		_colorHistory.Clear();
		_longEntries = 0;
		_shortEntries = 0;

		_ttmIndicator = new TtmTrendIndicator
		{
			CompBars = CompBars
		};

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

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

		var step = Security?.PriceStep ?? 1m;
		Unit stopLossUnit = StopLossPoints > 0m ? new Unit(StopLossPoints * step, UnitTypes.Absolute) : null;
		Unit takeProfitUnit = TakeProfitPoints > 0m ? new Unit(TakeProfitPoints * step, UnitTypes.Absolute) : null;

		if (stopLossUnit != null || takeProfitUnit != null)
			StartProtection(stopLossUnit, takeProfitUnit);
	}

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

		if (!ttmValue.IsFinal)
		return;

		var colorDecimal = ttmValue.GetValue<decimal>();
		var color = (int)Math.Round(colorDecimal);
		_colorHistory.Add(color);

		var offset = Math.Max(0, SignalBar - 1);
		var signalIndex = _colorHistory.Count - 1 - offset;
		if (signalIndex < 0)
		return;

		var currentColor = _colorHistory[signalIndex];
		int? previousColor = signalIndex > 0 ? _colorHistory[signalIndex - 1] : null;

		var isBullishColor = currentColor is 1 or 4;
		var isBearishColor = currentColor is 0 or 3;

		var wasBullish = previousColor.HasValue && (previousColor.Value == 1 || previousColor.Value == 4);
		var wasBearish = previousColor.HasValue && (previousColor.Value == 0 || previousColor.Value == 3);

		// Close existing positions before opening new ones.
		if (EnableLongExits && isBearishColor && Position > 0)
		{
			SellMarket(Position);
			_longEntries = 0;
		}

		if (EnableShortExits && isBullishColor && Position < 0)
		{
			BuyMarket(-Position);
			_shortEntries = 0;
		}

		// Open a fresh long when the color flips to bullish.
		if (EnableLongEntries && isBullishColor && previousColor.HasValue && !wasBullish && Position <= 0)
		{
			BuyMarket(Volume + (Position < 0 ? -Position : 0m));
			_longEntries = 1;
			_shortEntries = 0;
			_entryPrice = candle.ClosePrice;
		}
		// Open a fresh short when the color flips to bearish.
		else if (EnableShortEntries && isBearishColor && previousColor.HasValue && !wasBearish && Position >= 0)
		{
			SellMarket(Volume + (Position > 0 ? Position : 0m));
			_shortEntries = 1;
			_longEntries = 0;
			_entryPrice = candle.ClosePrice;
		}

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

		// Add to an existing long once price moves in favor.
		if (EnableLongEntries && Position > 0 && reentryStep > 0m && _longEntries > 0 && _longEntries < MaxPositions)
		{
			var distance = candle.ClosePrice - _entryPrice;
			if (distance >= reentryStep)
			{
				BuyMarket(Volume);
				_longEntries++;
				_entryPrice = candle.ClosePrice;
			}
		}
		// Add to an existing short once price moves in favor.
		else if (EnableShortEntries && Position < 0 && reentryStep > 0m && _shortEntries > 0 && _shortEntries < MaxPositions)
		{
			var distance = _entryPrice - candle.ClosePrice;
			if (distance >= reentryStep)
			{
				SellMarket(Volume);
				_shortEntries++;
				_entryPrice = candle.ClosePrice;
			}
		}

		var keep = Math.Max(offset + 2, 3);
		if (_colorHistory.Count > keep)
		_colorHistory.RemoveRange(0, _colorHistory.Count - keep);
	}

	/// <inheritdoc />
	protected override void OnPositionReceived(Position position)
	{
		base.OnPositionReceived(position);

		if (Position == 0m)
		{
			_longEntries = 0;
			_shortEntries = 0;
		}
	}

	/// <summary>
	/// Internal indicator reproducing the MetaTrader TTM Trend color output.
	/// </summary>
	private sealed class TtmTrendIndicator : BaseIndicator
	{
		private readonly List<TtmEntry> _history = [];
		private readonly object _sync = new();

		public int CompBars { get; set; } = 6;

		private decimal? _prevHaOpen;
		private decimal? _prevHaClose;

		/// <inheritdoc />
		protected override IIndicatorValue OnProcess(IIndicatorValue input)
		{
			lock (_sync)
			{
				ICandleMessage candle;
				try { candle = input.GetValue<ICandleMessage>(default); }
				catch { return new DecimalIndicatorValue(this, default, input.Time); }
				if (candle == null || candle.State != CandleStates.Finished)
					return new DecimalIndicatorValue(this, default, input.Time);

				var haClose = (candle.OpenPrice + candle.HighPrice + candle.LowPrice + candle.ClosePrice) / 4m;
				decimal haOpen;

				if (_prevHaOpen is null || _prevHaClose is null)
				{
					haOpen = (candle.OpenPrice + candle.ClosePrice) / 2m;
				}
				else
				{
					haOpen = (_prevHaOpen.Value + _prevHaClose.Value) / 2m;
				}

				_prevHaOpen = haOpen;
				_prevHaClose = haClose;

				var color = CalculateBaseColor(candle, haOpen, haClose);

				foreach (var entry in _history)
				{
					var high = Math.Max(entry.HaOpen, entry.HaClose);
					var low = Math.Min(entry.HaOpen, entry.HaClose);

					if (haOpen <= high && haOpen >= low && haClose <= high && haClose >= low)
					{
						color = entry.Color;
						break;
					}
				}

				_history.Insert(0, new TtmEntry(haOpen, haClose, color));

				while (_history.Count > Math.Max(1, CompBars))
					_history.RemoveAt(_history.Count - 1);

				IsFormed = true;
				return new DecimalIndicatorValue(this, color, input.Time);
			}
		}

		/// <inheritdoc />
		public override void Reset()
		{
			lock (_sync)
			{
				base.Reset();
				_history.Clear();
				_prevHaOpen = null;
				_prevHaClose = null;
			}
		}

		private static int CalculateBaseColor(ICandleMessage candle, decimal haOpen, decimal haClose)
		{
			const int neutral = 2;

			if (haClose > haOpen)
			return candle.OpenPrice <= candle.ClosePrice ? 4 : 3;

			if (haClose < haOpen)
			return candle.OpenPrice > candle.ClosePrice ? 0 : 1;

			return neutral;
		}

		private readonly record struct TtmEntry(decimal HaOpen, decimal HaClose, int Color);
	}
}