在 GitHub 上查看

Dealers Trade MACD 策略

本策略移植自 MQL5 的 “Dealers Trade v7.74 MACD” 专家顾问。它是一套顺势加仓系统,通过 MACD 主线的斜率决定多空方向,并在价格沿趋势推进时逐步扩大仓位。作者建议用于 H4、D1 等较高周期,以过滤短周期噪音。

策略逻辑

  • 信号判定:订阅所选周期的蜡烛图,在每根收盘 K 线计算 MACD 主线数值。主线向上视为看多,向下视为看空。参数 ReverseCondition 可以反转信号,用于需要反向交易的账户。
  • 仓位控制:第一笔订单使用固定手数 FixedVolume。若该值为 0,则改为使用账户当前权益 * RiskPercent(百分比)÷ 止损距离的方式动态确定手数。后续加仓单的体积按照 VolumeMultiplier^(当前仓位数量) 递增,同时要求价格距离上一次成交至少 IntervalPoints * PriceStep。若加仓后仓位数量超过 MaxPositions 或总量超过 MaxVolume,则放弃该信号。
  • 仓位管理:每笔持仓都记录独立的止损、止盈价格,根据参数 StopLossPointsTakeProfitPoints 计算(单位为最小报价步长)。当 TrailingStopPoints 大于 0 时,在浮盈超过 TrailingStopPoints + TrailingStepPoints 后启动追踪止损,模拟原始 EA 的移动保护逻辑。
  • 账户保护:当持仓数量大于 PositionsForProtection 且浮盈合计达到 SecureProfit 时,策略会先平掉盈利最高的那笔仓位锁定收益,然后再考虑继续加仓。

参数说明

参数 默认值 说明
CandleType H4 计算信号的蜡烛周期。
FixedVolume 0.1 第一笔订单的手数。设为 0 时启用按风险百分比的动态手数。
RiskPercent 5 FixedVolume = 0 时,允许冒的权益百分比。
StopLossPoints 90 止损距离(按价格最小变动单位计)。0 表示不下止损。
TakeProfitPoints 30 止盈距离(价格步长单位)。0 表示不设定。
TrailingStopPoints 15 移动止损的基础距离。0 表示关闭追踪。
TrailingStepPoints 5 每次更新移动止损前,额外需要的利润空间。
MaxPositions 5 最多允许的加仓次数。
IntervalPoints 15 相邻加仓所需的最小价格间隔(单位:价格步长)。
SecureProfit 50 触发账户保护的浮盈阈值(报价货币)。
AccountProtection true 是否启用账户保护机制。
PositionsForProtection 3 账户保护生效所需的最少持仓数量。
ReverseCondition false 是否反转 MACD 方向判断。
MacdFastPeriod 14 MACD 快速 EMA 周期。
MacdSlowPeriod 26 MACD 慢速 EMA 周期。
MacdSignalPeriod 1 MACD 信号 EMA 周期(与原始 EA 相同)。
MaxVolume 5 累计仓位数量上限。
VolumeMultiplier 1.6 每次加仓的手数倍增系数。

注意事项

  • MQL 版本允许同时持有多头与空头(对冲模式)。StockSharp 默认使用净额持仓,因此本移植版在反向开仓前会先平掉相反方向的仓位。
  • 策略只在蜡烛收盘后评估 MACD,因此不会像逐笔行情那样对瞬时波动做出反应,但更适合历史测试与实盘验证。
  • 所有“点数”参数都会乘以交易品种的 PriceStep。若品种没有提供该信息,则退回到 0.0001 的默认步长,请在必要时调整参数。
  • FixedVolume = 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>
/// Dealers Trade MACD strategy converted from MQL5 implementation.
/// </summary>
public class DealersTradeMacdStrategy : Strategy
{
	private readonly StrategyParam<DataType> _candleType;
	private readonly StrategyParam<decimal> _fixedVolume;
	private readonly StrategyParam<decimal> _riskPercent;
	private readonly StrategyParam<decimal> _stopLossPoints;
	private readonly StrategyParam<decimal> _takeProfitPoints;
	private readonly StrategyParam<decimal> _trailingStopPoints;
	private readonly StrategyParam<decimal> _trailingStepPoints;
	private readonly StrategyParam<int> _maxPositions;
	private readonly StrategyParam<decimal> _intervalPoints;
	private readonly StrategyParam<decimal> _secureProfit;
	private readonly StrategyParam<bool> _accountProtection;
	private readonly StrategyParam<int> _positionsForProtection;
	private readonly StrategyParam<bool> _reverseCondition;
	private readonly StrategyParam<int> _macdFastPeriod;
	private readonly StrategyParam<int> _macdSlowPeriod;
	private readonly StrategyParam<int> _macdSignalPeriod;
	private readonly StrategyParam<decimal> _maxVolume;
	private readonly StrategyParam<decimal> _volumeMultiplier;

	private MovingAverageConvergenceDivergence _macd = null!;
	private decimal? _previousMacd;
	private decimal _lastEntryPrice;
	private int _cooldown;
	private readonly List<PositionState> _longPositions = new();
	private readonly List<PositionState> _shortPositions = new();

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

		_fixedVolume = Param(nameof(FixedVolume), 0.1m)
			.SetDisplay("Fixed Volume", "Lot size used when above zero", "Risk");

		_riskPercent = Param(nameof(RiskPercent), 5m)
			.SetDisplay("Risk %", "Risk percent when fixed volume is zero", "Risk");

		_stopLossPoints = Param(nameof(StopLossPoints), 90m)
			.SetDisplay("Stop Loss pts", "Stop loss distance in price steps", "Risk")
			;

		_takeProfitPoints = Param(nameof(TakeProfitPoints), 30m)
			.SetDisplay("Take Profit pts", "Take profit distance in price steps", "Risk")
			;

		_trailingStopPoints = Param(nameof(TrailingStopPoints), 15m)
			.SetDisplay("Trailing Stop pts", "Trailing stop distance in price steps", "Risk");

		_trailingStepPoints = Param(nameof(TrailingStepPoints), 5m)
			.SetDisplay("Trailing Step pts", "Additional distance before trailing updates", "Risk");

		_maxPositions = Param(nameof(MaxPositions), 2)
			.SetDisplay("Max Positions", "Maximum concurrent entries", "Money Management");

		_intervalPoints = Param(nameof(IntervalPoints), 50m)
			.SetDisplay("Interval pts", "Minimum distance between new entries", "Money Management");

		_secureProfit = Param(nameof(SecureProfit), 50m)
			.SetDisplay("Secure Profit", "Profit threshold that triggers protection", "Money Management");

		_accountProtection = Param(nameof(AccountProtection), true)
			.SetDisplay("Account Protection", "Close best trade after reaching secure profit", "Money Management");

		_positionsForProtection = Param(nameof(PositionsForProtection), 3)
			.SetDisplay("Protect From", "Minimum positions before triggering protection", "Money Management");

		_reverseCondition = Param(nameof(ReverseCondition), false)
			.SetDisplay("Reverse Signal", "Invert MACD slope direction", "Trading");

		_macdFastPeriod = Param(nameof(MacdFastPeriod), 14)
			.SetDisplay("MACD Fast", "Fast EMA period", "Indicators");

		_macdSlowPeriod = Param(nameof(MacdSlowPeriod), 26)
			.SetDisplay("MACD Slow", "Slow EMA period", "Indicators");

		_macdSignalPeriod = Param(nameof(MacdSignalPeriod), 1)
			.SetDisplay("MACD Signal", "Signal EMA period", "Indicators");

		_maxVolume = Param(nameof(MaxVolume), 5m)
			.SetDisplay("Max Volume", "Absolute cap for trade volume", "Risk")
			.SetGreaterThanZero();

		_volumeMultiplier = Param(nameof(VolumeMultiplier), 1.6m)
			.SetDisplay("Volume Multiplier", "Multiplier for additional positions", "Money Management")
			.SetGreaterThanZero();
	}

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

	/// <summary>
	/// Fixed lot size. When zero risk based sizing is used.
	/// </summary>
	public decimal FixedVolume
	{
		get => _fixedVolume.Value;
		set => _fixedVolume.Value = value;
	}

	/// <summary>
	/// Percent of equity risked when sizing dynamically.
	/// </summary>
	public decimal RiskPercent
	{
		get => _riskPercent.Value;
		set => _riskPercent.Value = value;
	}

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

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

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

	/// <summary>
	/// Extra distance required before the trailing stop moves.
	/// </summary>
	public decimal TrailingStepPoints
	{
		get => _trailingStepPoints.Value;
		set => _trailingStepPoints.Value = value;
	}

	/// <summary>
	/// Maximum number of open entries.
	/// </summary>
	public int MaxPositions
	{
		get => _maxPositions.Value;
		set => _maxPositions.Value = value;
	}

	/// <summary>
	/// Minimum price distance between sequential entries.
	/// </summary>
	public decimal IntervalPoints
	{
		get => _intervalPoints.Value;
		set => _intervalPoints.Value = value;
	}

	/// <summary>
	/// Profit target for account protection logic.
	/// </summary>
	public decimal SecureProfit
	{
		get => _secureProfit.Value;
		set => _secureProfit.Value = value;
	}

	/// <summary>
	/// Enables profit locking when enough trades are open.
	/// </summary>
	public bool AccountProtection
	{
		get => _accountProtection.Value;
		set => _accountProtection.Value = value;
	}

	/// <summary>
	/// Minimum number of positions before account protection activates.
	/// </summary>
	public int PositionsForProtection
	{
		get => _positionsForProtection.Value;
		set => _positionsForProtection.Value = value;
	}

	/// <summary>
	/// Inverts the MACD slope direction.
	/// </summary>
	public bool ReverseCondition
	{
		get => _reverseCondition.Value;
		set => _reverseCondition.Value = value;
	}

	/// <summary>
	/// MACD fast EMA period.
	/// </summary>
	public int MacdFastPeriod
	{
		get => _macdFastPeriod.Value;
		set => _macdFastPeriod.Value = value;
	}

	/// <summary>
	/// MACD slow EMA period.
	/// </summary>
	public int MacdSlowPeriod
	{
		get => _macdSlowPeriod.Value;
		set => _macdSlowPeriod.Value = value;
	}

	/// <summary>
	/// MACD signal EMA period.
	/// </summary>
	public int MacdSignalPeriod
	{
		get => _macdSignalPeriod.Value;
		set => _macdSignalPeriod.Value = value;
	}

	/// <summary>
	/// Maximum allowed total volume.
	/// </summary>
	public decimal MaxVolume
	{
		get => _maxVolume.Value;
		set => _maxVolume.Value = value;
	}

	/// <summary>
	/// Multiplier applied to the base volume for each additional entry.
	/// </summary>
	public decimal VolumeMultiplier
	{
		get => _volumeMultiplier.Value;
		set => _volumeMultiplier.Value = value;
	}

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

	/// <inheritdoc />
	protected override void OnReseted()
	{
		base.OnReseted();
		_macd?.Reset();
		_previousMacd = null;
		_lastEntryPrice = 0m;
		_cooldown = 0;
		_longPositions.Clear();
		_shortPositions.Clear();
	}

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

		_macd = new MovingAverageConvergenceDivergence(
			new ExponentialMovingAverage { Length = MacdSlowPeriod },
			new ExponentialMovingAverage { Length = MacdFastPeriod }
		);

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

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

		HandleTrailingAndExits(candle);

		if (!IsFormedAndOnlineAndAllowTrading())
		{
			_previousMacd = macdValue;
			return;
		}

		if (_cooldown > 0)
		{
			_cooldown--;
			_previousMacd = macdValue;
			return;
		}

		var openPositions = _longPositions.Count + _shortPositions.Count;
		var continueOpening = openPositions < MaxPositions;

		var direction = 0;

		if (_previousMacd is null)
		{
			_previousMacd = macdValue;
			return;
		}

		if (macdValue > _previousMacd)
			direction = 1;
		else if (macdValue < _previousMacd)
			direction = -1;

		if (ReverseCondition)
			direction = -direction;

		if (AccountProtection && openPositions > PositionsForProtection)
		{
			var totalProfit = CalculateTotalProfit(candle.ClosePrice);
			if (totalProfit >= SecureProfit)
			{
				CloseMostProfitablePosition(candle.ClosePrice);
				_previousMacd = macdValue;
				return;
			}
		}

		if (continueOpening && direction > 0 && _shortPositions.Count == 0)
			TryOpenLong(candle);
		else if (continueOpening && direction < 0 && _longPositions.Count == 0)
			TryOpenShort(candle);

		_previousMacd = macdValue;
	}

	private void HandleTrailingAndExits(ICandleMessage candle)
	{
		var step = GetPriceStep();
		var trailingDistance = TrailingStopPoints * step;
		var trailingActivation = (TrailingStopPoints + TrailingStepPoints) * step;

		// Collect exits first, then execute to avoid collection modification during enumeration
		var longExits = new List<PositionState>();
		var longSnapshot = _longPositions.ToList();
		foreach (var state in longSnapshot)
		{
			if (state.TakeProfitPrice > 0 && candle.HighPrice >= state.TakeProfitPrice)
			{
				longExits.Add(state);
				continue;
			}

			if (state.StopPrice > 0 && candle.LowPrice <= state.StopPrice)
			{
				longExits.Add(state);
				continue;
			}

			if (TrailingStopPoints > 0 && candle.ClosePrice - state.EntryPrice > trailingActivation)
			{
				var candidateStop = candle.ClosePrice - trailingDistance;
				if (state.StopPrice == 0m || state.StopPrice < candle.ClosePrice - trailingActivation)
					state.StopPrice = candidateStop;
			}
		}
		foreach (var state in longExits)
		{
			Volume = state.Volume;
			SellMarket();
			_longPositions.Remove(state);
			_lastEntryPrice = 0m;
		}

		var shortExits = new List<PositionState>();
		var shortSnapshot = _shortPositions.ToList();
		foreach (var state in shortSnapshot)
		{
			if (state.TakeProfitPrice > 0 && candle.LowPrice <= state.TakeProfitPrice)
			{
				shortExits.Add(state);
				continue;
			}

			if (state.StopPrice > 0 && candle.HighPrice >= state.StopPrice)
			{
				shortExits.Add(state);
				continue;
			}

			if (TrailingStopPoints > 0 && state.EntryPrice - candle.ClosePrice > trailingActivation)
			{
				var candidateStop = candle.ClosePrice + trailingDistance;
				if (state.StopPrice == 0m || state.StopPrice > candle.ClosePrice + trailingActivation)
					state.StopPrice = candidateStop;
			}
		}
		foreach (var state in shortExits)
		{
			Volume = state.Volume;
			BuyMarket();
			_shortPositions.Remove(state);
			_lastEntryPrice = 0m;
		}
	}

	private void TryOpenLong(ICandleMessage candle)
	{
		var step = GetPriceStep();
		var interval = IntervalPoints * step;

		if (_lastEntryPrice != 0m && Math.Abs(_lastEntryPrice - candle.ClosePrice) < interval)
			return;

		var baseVolume = FixedVolume > 0 ? FixedVolume : CalculateRiskVolume(step);
		if (baseVolume <= 0)
			return;

		var openPositions = _longPositions.Count + _shortPositions.Count;
		var lotCoefficient = openPositions == 0 ? 1m : Pow(VolumeMultiplier, openPositions + 1);
		var volume = NormalizeVolume(baseVolume * lotCoefficient);
		if (volume <= 0 || volume > MaxVolume)
			return;

		var stopDistance = StopLossPoints * step;
		var takeDistance = TakeProfitPoints * step;

		Volume = volume;
		BuyMarket();

		_longPositions.Add(new PositionState
		{
			EntryPrice = candle.ClosePrice,
			Volume = volume,
			StopPrice = stopDistance > 0 ? candle.ClosePrice - stopDistance : 0m,
			TakeProfitPrice = takeDistance > 0 ? candle.ClosePrice + takeDistance : 0m
		});

		_lastEntryPrice = candle.ClosePrice;
		_cooldown = 3;
	}

	private void TryOpenShort(ICandleMessage candle)
	{
		var step = GetPriceStep();
		var interval = IntervalPoints * step;

		if (_lastEntryPrice != 0m && Math.Abs(_lastEntryPrice - candle.ClosePrice) < interval)
			return;

		var baseVolume = FixedVolume > 0 ? FixedVolume : CalculateRiskVolume(step);
		if (baseVolume <= 0)
			return;

		var openPositions = _longPositions.Count + _shortPositions.Count;
		var lotCoefficient = openPositions == 0 ? 1m : Pow(VolumeMultiplier, openPositions + 1);
		var volume = NormalizeVolume(baseVolume * lotCoefficient);
		if (volume <= 0 || volume > MaxVolume)
			return;

		var stopDistance = StopLossPoints * step;
		var takeDistance = TakeProfitPoints * step;

		Volume = volume;
		SellMarket();

		_shortPositions.Add(new PositionState
		{
			EntryPrice = candle.ClosePrice,
			Volume = volume,
			StopPrice = stopDistance > 0 ? candle.ClosePrice + stopDistance : 0m,
			TakeProfitPrice = takeDistance > 0 ? candle.ClosePrice - takeDistance : 0m
		});

		_lastEntryPrice = candle.ClosePrice;
		_cooldown = 3;
	}

	private decimal CalculateRiskVolume(decimal priceStep)
	{
		if (StopLossPoints <= 0)
			return 0m;

		var stopDistance = StopLossPoints * priceStep;
		if (stopDistance <= 0)
			return 0m;

		if (Portfolio is null)
			return 0m;

		var equity = Portfolio.CurrentValue ?? 0m;
		if (equity <= 0)
			return 0m;

		var riskAmount = equity * (RiskPercent / 100m);
		return riskAmount / stopDistance;
	}

	private decimal CalculateTotalProfit(decimal currentPrice)
	{
		decimal profit = 0m;

		foreach (var pos in _longPositions)
			profit += (currentPrice - pos.EntryPrice) * pos.Volume;

		foreach (var pos in _shortPositions)
			profit += (pos.EntryPrice - currentPrice) * pos.Volume;

		return profit;
	}

	private void CloseMostProfitablePosition(decimal currentPrice)
	{
		PositionState best = null;
		var bestIsLong = false;
		decimal bestProfit = 0m;

		foreach (var pos in _longPositions)
		{
			var profit = (currentPrice - pos.EntryPrice) * pos.Volume;
			if (profit > bestProfit)
			{
				bestProfit = profit;
				best = pos;
				bestIsLong = true;
			}
		}

		foreach (var pos in _shortPositions)
		{
			var profit = (pos.EntryPrice - currentPrice) * pos.Volume;
			if (profit > bestProfit)
			{
				bestProfit = profit;
				best = pos;
				bestIsLong = false;
			}
		}

		if (best is null || bestProfit <= 0m)
			return;

		if (bestIsLong)
		{
			SellMarket();
			_longPositions.Remove(best);
		}
		else
		{
			BuyMarket();
			_shortPositions.Remove(best);
		}

		_lastEntryPrice = 0m;
	}

	private decimal NormalizeVolume(decimal volume)
	{
		if (volume <= 0)
			return 0m;

		var step = Security?.VolumeStep ?? 0m;
		if (step > 0)
		{
			var steps = Math.Floor(volume / step);
			volume = steps * step;
		}

		return volume;
	}

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

		var decimals = Security?.Decimals ?? 0;
		if (decimals > 0)
			return (decimal)Math.Pow(10, -decimals);

		return 0.0001m;
	}

	private static decimal Pow(decimal value, int power)
	{
		if (power <= 0)
			return 1m;

		return (decimal)Math.Pow((double)value, power);
	}

	private sealed class PositionState
	{
		public decimal EntryPrice { get; set; }
		public decimal Volume { get; set; }
		public decimal StopPrice { get; set; }
		public decimal TakeProfitPrice { get; set; }
	}
}