在 GitHub 上查看

Force Trend 策略

概述

  • 将 MetaTrader 5 顾问 Exp_ForceTrend.mq5(目录 MQL/18817)转换为 StockSharp 策略。
  • 使用 ForceTrend 振荡指标来识别多空动量的切换。
  • 基于 StockSharp 的高级 API 实现:订阅K线、绑定内置指标,而不是直接访问历史数组。

ForceTrend 指标

  • 在最近 Length 根K线中寻找最高价与最低价的区间。
  • 将当前K线的中间价标准化到该区间内,并进行两次平滑:
    • 第一步通过系数 0.660.67 得到中间 force 值;
    • 第二步对 force 值进行对数变换并与前值做半衰期平滑,得到最终的 ForceTrend 数值。
  • 数值大于零视为多头(原指标以蓝色显示),数值小于零视为空头(原指标以洋红色显示)。

参数

  • Length —— ForceTrend 计算窗口长度,必须为正数。
  • SignalBar —— 信号偏移的已完成K线数量。0 表示使用最新收盘K线,1 与 MT5 默认设置相同,等待一根额外K线,数值越大反应越慢。
  • EnableLongEntry —— 是否允许在多头信号出现时开多。
  • EnableShortEntry —— 是否允许在空头信号出现时开空。
  • EnableLongExit —— 是否允许在空头信号出现时平多。
  • EnableShortExit —— 是否允许在多头信号出现时平空。
  • CandleType —— 用于计算指标的K线类型/周期。

交易规则

  1. ForceTrend 数值被转换为离散方向(+10-1)。
  2. 使用固定长度的方向历史数组,对比 SignalBar 偏移位置与前一根的方向。
  3. 当方向为多头 (direction > 0) 时:
    • EnableShortExit = true,平掉所有空头仓位(数量为 |Position|)。
    • 若前一个方向不是多头且 EnableLongEntry = true,则以市价下单,数量为 Volume + |Position|,实现开多或反手。
  4. 当方向为空头 (direction < 0) 时执行对称操作,依据 EnableLongExitEnableShortEntry 控制。
  5. 当指标为零时,沿用上一次有效方向,避免在零附近反复切换。
  6. 只有在策略完全就绪且允许运行时(IsFormedAndOnlineAndAllowTrading)才会发送订单。

实现说明

  • 通过 SubscribeCandles(CandleType) 订阅K线,ProcessCandle 回调中完成所有计算。
  • 借助 HighestLowest 指标获取区间极值,无需手工维护列表或使用 LINQ。
  • 方向历史存放在启动时预分配的固定数组中,以复现 MT5 的 SignalBar 行为并避免频繁分配。
  • 反手交易只提交一张市价单,数量等于目标持仓与当前绝对持仓之和,对应 MQL 中的 BuyPositionOpen / SellPositionOpen 函数。
  • 原顾问的资金管理、点数止盈止损和滑点参数未迁移;在 StockSharp 中通过 Volume 或外部保护模块自行控制风险。
  • 布尔开关直接对应 MT5 输入参数(BuyPosOpenSellPosOpenBuyPosCloseSellPosClose)。

使用建议

  • 启动前设置策略的 Volume 属性以控制下单数量。
  • 选择与 MT5 测试相符的 CandleType(默认使用四小时K线)。
  • 如需自动止损/止盈,可结合 StockSharp 的保护功能(例如 StartProtection)。

文件

  • 策略实现:CS/ForceTrendStrategy.cs
  • 原始 MQL 文件:MQL/18817/mql5/Experts/Exp_ForceTrend.mq5MQL/18817/mql5/Indicators/ForceTrend.mq5
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;

using StockSharp.Algo;

namespace StockSharp.Samples.Strategies;

/// <summary>
/// Force Trend strategy that mirrors the original MT5 expert advisor logic.
/// It reacts to ForceTrend indicator color changes to switch between long and short positions.
/// </summary>
public class ForceTrendStrategy : Strategy
{
	private readonly StrategyParam<int> _length;
	private readonly StrategyParam<int> _signalBar;
	private readonly StrategyParam<bool> _enableLongEntry;
	private readonly StrategyParam<bool> _enableShortEntry;
	private readonly StrategyParam<bool> _enableLongExit;
	private readonly StrategyParam<bool> _enableShortExit;
	private readonly StrategyParam<DataType> _candleType;

	private Highest _highest = null!;
	private Lowest _lowest = null!;
	private decimal _previousForceValue;
	private decimal _previousIndicatorValue;
	private int?[] _directionHistory = Array.Empty<int?>();
	private int _historyCount;
	private int? _lastKnownDirection;

	/// <summary>
	/// Initializes strategy parameters.
	/// </summary>
	public ForceTrendStrategy()
	{
		_length = Param(nameof(Length), 13)
			.SetDisplay("Length", "ForceTrend lookback length", "Indicator")
			.SetGreaterThanZero()
			;

		_signalBar = Param(nameof(SignalBar), 1)
			.SetDisplay("Signal Bar", "Number of finished bars to shift the signal", "Trading")
			;

		_enableLongEntry = Param(nameof(EnableLongEntry), true)
			.SetDisplay("Enable Long Entry", "Allow opening long positions", "Trading")
			;

		_enableShortEntry = Param(nameof(EnableShortEntry), true)
			.SetDisplay("Enable Short Entry", "Allow opening short positions", "Trading")
			;

		_enableLongExit = Param(nameof(EnableLongExit), true)
			.SetDisplay("Enable Long Exit", "Allow closing long positions", "Trading")
			;

		_enableShortExit = Param(nameof(EnableShortExit), true)
			.SetDisplay("Enable Short Exit", "Allow closing short positions", "Trading")
			;

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
			.SetDisplay("Candle Type", "Timeframe used for ForceTrend calculations", "General");
	}

	/// <summary>
	/// ForceTrend lookback length.
	/// </summary>
	public int Length
	{
		get => _length.Value;
		set => _length.Value = value;
	}

	/// <summary>
	/// Number of finished candles used to shift the trade signal.
	/// </summary>
	public int SignalBar
	{
		get => _signalBar.Value;
		set => _signalBar.Value = value;
	}

	/// <summary>
	/// Enable opening long positions when the ForceTrend becomes bullish.
	/// </summary>
	public bool EnableLongEntry
	{
		get => _enableLongEntry.Value;
		set => _enableLongEntry.Value = value;
	}

	/// <summary>
	/// Enable opening short positions when the ForceTrend becomes bearish.
	/// </summary>
	public bool EnableShortEntry
	{
		get => _enableShortEntry.Value;
		set => _enableShortEntry.Value = value;
	}

	/// <summary>
	/// Enable closing long positions on bearish ForceTrend signals.
	/// </summary>
	public bool EnableLongExit
	{
		get => _enableLongExit.Value;
		set => _enableLongExit.Value = value;
	}

	/// <summary>
	/// Enable closing short positions on bullish ForceTrend signals.
	/// </summary>
	public bool EnableShortExit
	{
		get => _enableShortExit.Value;
		set => _enableShortExit.Value = value;
	}

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

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

		_previousForceValue = 0m;
		_previousIndicatorValue = 0m;
		_directionHistory = Array.Empty<int?>();
		_historyCount = 0;
		_lastKnownDirection = null;
	}

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

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

		_previousForceValue = 0m;
		_previousIndicatorValue = 0m;
		_historyCount = 0;
		_lastKnownDirection = null;
		_directionHistory = new int?[Math.Max(SignalBar + 2, 2)];

		_highest = new Highest { Length = Length };
		_lowest = new Lowest { Length = Length };

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

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

		var highestValue = _highest.Process(new CandleIndicatorValue(_highest, candle)).ToDecimal();
		var lowestValue = _lowest.Process(new CandleIndicatorValue(_lowest, candle)).ToDecimal();

		if (!_highest.IsFormed || !_lowest.IsFormed)
			return;

		var range = highestValue - lowestValue;
		decimal forceValue;

		if (range != 0m)
		{
			var average = (candle.HighPrice + candle.LowPrice) / 2m;
			var normalized = (average - lowestValue) / range - 0.5m;
			forceValue = 0.66m * normalized + 0.67m * _previousForceValue;
		}
		else
		{
			forceValue = 0.67m * _previousForceValue - 0.33m;
		}

		forceValue = Math.Clamp(forceValue, -0.999m, 0.999m);

		decimal indicatorValue;
		var denominator = 1m - forceValue;

		if (denominator != 0m)
		{
			var ratio = (forceValue + 1m) / denominator;
			indicatorValue = (decimal)(Math.Log((double)ratio) / 2.0) + _previousIndicatorValue / 2m;
		}
		else
		{
			indicatorValue = _previousIndicatorValue / 2m + 0.5m;
		}

		_previousForceValue = forceValue;
		_previousIndicatorValue = indicatorValue;

		var direction = indicatorValue > 0m ? 1 : indicatorValue < 0m ? -1 : _lastKnownDirection ?? 0;
		if (direction != 0)
			_lastKnownDirection = direction;

		AddDirection(direction);

		var currentDirection = GetDirection(SignalBar);
		if (currentDirection is null)
			return;

		var previousDirection = GetDirection(SignalBar + 1);
		var bullish = currentDirection.Value > 0;
		var bearish = currentDirection.Value < 0;
		var bullishFlip = bullish && previousDirection.HasValue && previousDirection.Value <= 0;
		var bearishFlip = bearish && previousDirection.HasValue && previousDirection.Value >= 0;

		// indicators processed manually, no BindEx

		if (bullish)
		{
			var volumeToBuy = 0m;

			if (EnableShortExit && Position < 0m)
				volumeToBuy += Math.Abs(Position);

			if (EnableLongEntry && bullishFlip && Position <= 0m)
				volumeToBuy += Volume;

			if (volumeToBuy > 0m)
				BuyMarket();
		}
		else if (bearish)
		{
			var volumeToSell = 0m;

			if (EnableLongExit && Position > 0m)
				volumeToSell += Math.Abs(Position);

			if (EnableShortEntry && bearishFlip && Position >= 0m)
				volumeToSell += 1m;

			if (volumeToSell > 0m)
				SellMarket();
		}
	}

	private void AddDirection(int direction)
	{
		if (_historyCount < _directionHistory.Length)
		{
			_directionHistory[_historyCount] = direction;
			_historyCount++;
		}
		else
		{
			for (var i = 1; i < _directionHistory.Length; i++)
				_directionHistory[i - 1] = _directionHistory[i];

			_directionHistory[^1] = direction;
		}
	}

	private int? GetDirection(int offset)
	{
		if (offset < 0)
			return null;

		var index = _historyCount - 1 - offset;
		if (index < 0)
			return null;

		return _directionHistory[index];
	}
}