在 GitHub 上查看

ErrorEA 策略

概述

ErrorEA 是 MetaTrader 专家顾问 errorEA.mq4 的 StockSharp 移植版本。原始 EA 通过比较 Average Directional Index 指标的 +DI 与 -DI 线,在确认趋势方向后不断加仓,同时放置一个非常远的止损与一个很小的剥头皮止盈。C# 版本沿用了同样的思路,使用 StockSharp 的高级 API 实现下单,并在文档中详细说明风险控制规则。

交易逻辑

  1. 订阅参数 CandleType 指定的时间框架,并将蜡烛数据传入 AverageDirectionalIndex 指标。
  2. 仅在蜡烛收盘后处理信号,确保 ADX 返回最终值。
  3. 比较 +DI 与 -DI:
    • +DI > -DI 视为多头趋势;
    • -DI > +DI 视为空头趋势;
    • 数值相等时不生成新信号。
  4. 多头信号触发时:
    • 先平掉已有的空头净头寸(StockSharp 采用净额模式,不允许双向锁仓);
    • 若多头加仓次数尚未达到 MaxTrades,按照风险控制计算的手数再买入一笔市价单。
  5. 空头信号触发时:
    • 平掉已有多头净头寸;
    • 若空头加仓次数未超过 MaxTrades,按同样的仓位计算规则卖出一笔市价单。
  6. StartProtection 负责保护单:
    • StopLossPoints 按价格步长转换为止损距离,对应原 EA 中的 StopLoss
    • EnableTakeProfit 为真时,TakeProfitPoints 重现了 ScalpeProfit 的短期止盈逻辑。
  7. _longTrades_shortTrades 计数器在仓位归零或方向反转时重置,保证累积次数不会超过 MaxTrades

风险与仓位管理

  • BaseVolume 等同于原 EA 的 MiniLots,定义基础下单手数。
  • EnableRiskControl 启用时,会执行原始公式 PowerRiskvolume = BaseVolume * max(1, PortfolioValue / RiskDivider),默认除数 10000 与 MQL 程序保持一致。
  • 计算出的仓位会被限制在 MinVolumeMaxVolume 范围内,并根据交易所参数 (Security.MinVolumeSecurity.MaxVolumeSecurity.VolumeStep) 对齐,避免提交无效手数。
  • 只要方向未触及 MaxTrades 限制,每次加仓都会使用同一风险模型得出的数量。

参数

名称 类型 默认值 MetaTrader 对应项 说明
AdxPeriod int 14 iADX(..., 14, ...) ADX 平滑周期。
CandleType DataType 15 分钟 图表时间框架 用于计算的蜡烛类型。
MaxTrades int 9 MaxTrades 同方向允许的最大加仓次数。
EnableRiskControl bool true RiskControl 是否按账户价值动态计算手数。
BaseVolume decimal 0.15 MiniLots 风险计算前的基础手数。
RiskDivider decimal 10000 PowerRisk 中的除数 控制风险倍率的分母。
MaxVolume decimal 3 MaxLot 自动计算后允许的最大手数。
MinVolume decimal 0.01 MODE_MINLOT 市场允许的最小手数。
StopLossPoints int 1000 StopLoss 止损距离(价格步数,0 表示禁用)。
EnableTakeProfit bool true ScalpeControl 是否启用剥头皮式止盈。
TakeProfitPoints int 10 ScalpeProfit 止盈距离(价格步数)。

与原版 EA 的差异

  • 原 MQL 代码存在 bug,会把 +DI 的值覆盖成 -DI。本移植修正了该问题,使交易逻辑符合作者意图。
  • MetaTrader 支持锁仓,StockSharp 使用净额模式,因此在开仓前会平掉反向持仓。
  • GetSlippageComment 输出被移除,因为在 StockSharp 中它们只提供装饰信息,不影响交易。
  • OrderModify 的止损/止盈修改由一次 StartProtection 调用取代,同时考虑了交易所的最小步长与限制。

使用建议

  • 确认品种的 PriceStepVolumeStepMinVolumeMaxVolume 信息完整,以便正确对齐手数。
  • 根据交易所规则调整 BaseVolumeMinVolumeMaxVolume。构造函数也会把基础手数写入 Strategy.Volume,方便界面上的手工操作。
  • 如果 +DI/-DI 信号过于嘈杂,可适当调高 AdxPeriod 或选择更长时间框架。
  • 假如更倾向于只使用止损离场,可以关闭 EnableTakeProfit
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>
/// Port of the "errorEA" MetaTrader strategy that compares +DI and -DI lines of ADX.
/// </summary>
public class ErrorEaStrategy : Strategy
{
	private readonly StrategyParam<int> _adxPeriod;
	private readonly StrategyParam<int> _maxTrades;
	private readonly StrategyParam<bool> _enableRiskControl;
	private readonly StrategyParam<decimal> _maxVolume;
	private readonly StrategyParam<decimal> _minVolume;
	private readonly StrategyParam<decimal> _baseVolume;
	private readonly StrategyParam<decimal> _riskDivider;
	private readonly StrategyParam<int> _stopLossPoints;
	private readonly StrategyParam<bool> _enableTakeProfit;
	private readonly StrategyParam<int> _takeProfitPoints;
	private readonly StrategyParam<DataType> _candleType;

	private AverageDirectionalIndex _adx;
	private int _longTrades;
	private int _shortTrades;

	/// <summary>
	/// ADX averaging period.
	/// </summary>
	public int AdxPeriod
	{
		get => _adxPeriod.Value;
		set => _adxPeriod.Value = value;
	}

	/// <summary>
	/// Maximum number of scale-in entries per direction.
	/// </summary>
	public int MaxTrades
	{
		get => _maxTrades.Value;
		set => _maxTrades.Value = value;
	}

	/// <summary>
	/// Enables dynamic position sizing based on the portfolio value.
	/// </summary>
	public bool EnableRiskControl
	{
		get => _enableRiskControl.Value;
		set => _enableRiskControl.Value = value;
	}

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

	/// <summary>
	/// Minimum order volume that should be used.
	/// </summary>
	public decimal MinVolume
	{
		get => _minVolume.Value;
		set => _minVolume.Value = value;
	}

	/// <summary>
	/// Base volume multiplier that matches the MiniLots parameter from MQL.
	/// </summary>
	public decimal BaseVolume
	{
		get => _baseVolume.Value;
		set => _baseVolume.Value = value;
	}

	/// <summary>
	/// Divider applied to the portfolio value when risk control is enabled.
	/// </summary>
	public decimal RiskDivider
	{
		get => _riskDivider.Value;
		set => _riskDivider.Value = value;
	}

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

	/// <summary>
	/// Enables the scalping take-profit mode from the original EA.
	/// </summary>
	public bool EnableTakeProfit
	{
		get => _enableTakeProfit.Value;
		set => _enableTakeProfit.Value = value;
	}

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

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

	/// <summary>
	/// Initializes a new instance of the <see cref="ErrorEaStrategy"/> class.
	/// </summary>
	public ErrorEaStrategy()
	{
		_adxPeriod = Param(nameof(AdxPeriod), 14)
			.SetRange(5, 50)
			.SetDisplay("ADX Period", "Smoothing period for the Average Directional Index", "Indicators")
			;

		_maxTrades = Param(nameof(MaxTrades), 9)
			.SetRange(1, 15)
			.SetDisplay("Max Trades", "Maximum number of simultaneous entries per direction", "Risk")
			;

		_enableRiskControl = Param(nameof(EnableRiskControl), true)
			.SetDisplay("Enable Risk Control", "Adjust volume by portfolio value similar to the MQL version", "Risk");

		_maxVolume = Param(nameof(MaxVolume), 3m)
			.SetNotNegative()
			.SetDisplay("Max Volume", "Upper limit for market orders", "Risk");

		_minVolume = Param(nameof(MinVolume), 0.01m)
			.SetNotNegative()
			.SetDisplay("Min Volume", "Lower limit for market orders", "Risk");

		_baseVolume = Param(nameof(BaseVolume), 0.15m)
			.SetNotNegative()
			.SetDisplay("Base Volume", "Base lot used before applying risk control", "Risk")
			;

		_riskDivider = Param(nameof(RiskDivider), 10000m)
			.SetNotNegative()
			.SetDisplay("Risk Divider", "Portfolio divider used to scale volume when risk control is enabled", "Risk")
			;

		_stopLossPoints = Param(nameof(StopLossPoints), 1000)
			.SetNotNegative()
			.SetDisplay("Stop Loss Points", "Stop distance converted to price steps", "Protection")
			;

		_enableTakeProfit = Param(nameof(EnableTakeProfit), true)
			.SetDisplay("Enable Take Profit", "Activate the small scalping take profit from the EA", "Protection");

		_takeProfitPoints = Param(nameof(TakeProfitPoints), 10)
			.SetNotNegative()
			.SetDisplay("Take Profit Points", "Take-profit distance converted to price steps", "Protection")
			;

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

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

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

		_adx = null;
		_longTrades = 0;
		_shortTrades = 0;

		Volume = BaseVolume;
	}

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

		_adx = new AverageDirectionalIndex { Length = AdxPeriod };

		// Subscribe to the configured candle series and calculate ADX on the fly.
		var subscription = SubscribeCandles(CandleType);
		subscription
			.BindEx(_adx, ProcessCandle)
			.Start();

		var takeProfitUnit = EnableTakeProfit && TakeProfitPoints > 0
			? new Unit(TakeProfitPoints, UnitTypes.Absolute)
			: null;
		var stopLossUnit = StopLossPoints > 0
			? new Unit(StopLossPoints, UnitTypes.Absolute)
			: null;

		// Mirror the original stop-loss and scalping take-profit distances.
		StartProtection(
			takeProfit: takeProfitUnit,
			stopLoss: stopLossUnit,
			useMarketOrders: true);

		// Preload the base volume so manual actions in the UI use the same size.
		var adjustedVolume = AdjustVolume(BaseVolume);
		Volume = adjustedVolume > 0m ? adjustedVolume : BaseVolume;

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

	private void ProcessCandle(ICandleMessage candle, IIndicatorValue adxValue)
	{
		// Only evaluate completed candles.
		if (candle.State != CandleStates.Finished)
			return;

		// Wait until ADX indicator is formed.
		if (!_adx.IsFormed)
			return;

		// Ensure ADX produced a final value for this bar.
		if (adxValue is not AverageDirectionalIndexValue adx || !adxValue.IsFinal)
			return;

		var plusDi = adx.Dx.Plus ?? 0m;
		var minusDi = adx.Dx.Minus ?? 0m;

		// Compare +DI and -DI components to determine the signal.
		var direction = CalculateDirection(plusDi, minusDi);

		switch (direction)
		{
			case > 0:
				HandleLongSignal();
				break;
			case < 0:
				HandleShortSignal();
				break;
			default:
				break;
		}
	}

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

		// Reset scaling counters once the net position flips or becomes flat.
		if (Position == 0)
		{
			_longTrades = 0;
			_shortTrades = 0;
		}
		else if (Position > 0)
		{
			_shortTrades = 0;
		}
		else
		{
			_longTrades = 0;
		}
	}

	private int CalculateDirection(decimal plusDi, decimal minusDi)
	{
		if (plusDi > minusDi)
			return 1;

		if (minusDi > plusDi)
			return -1;

		return 0;
	}

	private void HandleLongSignal()
	{
		if (Security is null)
			return;

		// Netting accounts cannot keep opposite positions, so close shorts first.
		if (Position < 0)
		{
			BuyMarket(Math.Abs(Position));
			_shortTrades = 0;
		}

		// Respect the scaling cap inherited from the original EA.
		if (_longTrades >= MaxTrades)
			return;

		var volume = CalculateOrderVolume();
		if (volume <= 0m)
			return;

		// Add one more market order using the calculated lot size.
		BuyMarket(volume);
		_longTrades++;
	}

	private void HandleShortSignal()
	{
		if (Security is null)
			return;

		// Flat the long exposure before opening new short trades.
		if (Position > 0)
		{
			SellMarket(Math.Abs(Position));
			_longTrades = 0;
		}

		if (_shortTrades >= MaxTrades)
			return;

		var volume = CalculateOrderVolume();
		if (volume <= 0m)
			return;

		SellMarket(volume);
		_shortTrades++;
	}

	private decimal CalculateOrderVolume()
	{
		// Start from the base lot size defined by BaseVolume.
		var volume = BaseVolume;

		if (EnableRiskControl)
		{
			// Reproduce the PowerRisk logic: balance / divider with a floor of 1.
			var portfolioValue = Portfolio?.CurrentValue ?? Portfolio?.BeginValue ?? 0m;
			if (portfolioValue <= 0m)
				portfolioValue = 0m;

			var riskFactor = RiskDivider > 0m ? portfolioValue / RiskDivider : 0m;

			if (riskFactor < 1m)
				riskFactor = 1m;

			volume *= riskFactor;
		}

		// Apply user-defined caps before exchange-specific adjustments.
		if (MaxVolume > 0m && volume > MaxVolume)
			volume = MaxVolume;

		if (MinVolume > 0m && volume < MinVolume)
			volume = MinVolume;

		// Align with exchange volume constraints.
		var adjusted = AdjustVolume(volume);
		if (MaxVolume > 0m && adjusted > MaxVolume)
			adjusted = MaxVolume;

		if (adjusted <= 0m && MinVolume > 0m)
			adjusted = MinVolume;

		return adjusted;
	}

	private decimal AdjustVolume(decimal volume)
	{
		if (Security is null)
			return volume;

		var step = Security.VolumeStep ?? 0m;
		if (step > 0m)
		{
			// Round the value to the nearest allowed volume step.
			var rounded = step * Math.Floor(volume / step);
			volume = rounded > 0m ? rounded : step;
		}

		var minVolume = Security.MinVolume ?? 0m;
		if (minVolume > 0m && volume < minVolume)
			volume = minVolume;

		var maxVolume = Security.MaxVolume;
		if (maxVolume != null && maxVolume.Value > 0m && volume > maxVolume.Value)
			volume = maxVolume.Value;

		return volume;
	}
}