在 GitHub 上查看

Up3x1 策略(MT5 版本转换)

概述

  • 将 MetaTrader 5 专家顾问 up3x1.mq5 转换为 StockSharp 高层 API 策略实现。
  • 采用三重指数移动平均线(EMA)交叉信号,并带有止损、止盈和移动止损管理。
  • 仅在 K 线收盘后处理数据,以模拟原始脚本中 iTickVolume(0) > 1 的判断(每根柱子只处理一次)。
  • 默认使用 1 小时 K 线,可通过 CandleType 参数自定义时间框。

交易逻辑

  1. 指标配置
    • 快速 EMA(FastPeriod,默认 24)。
    • 中速 EMA(MediumPeriod,默认 60)。
    • 慢速 EMA(SlowPeriod,默认 120)。
  2. 做多条件
    • 上一根柱子:快速 EMA 低于中速 EMA,且中速 EMA 低于慢速 EMA。
    • 当前柱子:中速 EMA 上穿快速 EMA,同时快速 EMA 仍低于慢速 EMA。
  3. 做空条件
    • 上一根柱子:快速 EMA 高于中速 EMA,且中速 EMA 高于慢速 EMA。
    • 当前柱子:中速 EMA 上穿快速 EMA,同时二者仍高于慢速 EMA。
  4. 平仓规则(多空共用)
    • 价格相对入场价前进 TakeProfitOffset 即止盈(多单用最高价,空单用最低价判断)。
    • 价格相对入场价回撤 StopLossOffset 即止损(多单看最低价,空单看最高价)。
    • 当浮动盈利超过 TrailingStopOffset 后启用移动止损,按照固定距离跟随价格(使用柱状图高低价评估)。
    • 若快速 EMA 再次下穿中速 EMA 且二者保持在慢速 EMA 上方,则强制平仓(复刻 MQL 代码中的 ma_one_1 > ma_two_1 > ma_three_1 判断)。

仓位与风险管理

  • RiskFraction(默认 0.02)与当前账户净值相乘,近似原脚本 FreeMargin * 0.02 / 1000 的动态手数公式。
  • BaseVolume(默认 0.1)在无法取得组合价值或计算结果非正时作为备用手数。
  • 当连续亏损次数超过 1 次后,仓位将按 volume * losses / 3 的比例减少;losses 计数在盈利后不会清零,与原脚本保持一致。
  • 成交量会向下对齐至 Security.VolumeStep,并受 Security.MinVolumeSecurity.MaxVolume 约束;若无法满足最小手数则放弃下单。

参数列表

参数 默认值 说明
FastPeriod 24 快速 EMA 周期。
MediumPeriod 60 中速 EMA 周期。
SlowPeriod 120 慢速 EMA 周期,用于趋势过滤。
TakeProfitOffset 0.015 止盈距离(绝对价格差,需根据品种报价调整)。
StopLossOffset 0.01 止损距离(绝对价格差)。
TrailingStopOffset 0.004 移动止损距离,设置为 0 可禁用。
BaseVolume 0.1 动态手数失效时的备用仓位大小。
RiskFraction 0.02 用于动态仓位计算的账户价值比例。
CandleType 1 小时 指标计算与信号触发所用的 K 线类型。

转换说明

  • 由于高层 API 按收盘柱处理数据,移动止损与保护性出场基于 K 线最高/最低价评估,而非逐笔报价,以便在回测与实盘中保持一致性。
  • 止损与止盈通过市价平仓执行,而不是挂出保护性委托,从而与高层策略框架兼容。
  • 动态仓位依赖 Portfolio.CurrentValue,若该数据不可用则退回 BaseVolume,类似原脚本 LotCheck 回退到手工输入手数。
  • losses 计数不会因盈利而清零,完全沿用 MQL 脚本的累积逻辑。
  • 所有代码注释均使用英文,以符合项目规范。

使用建议

  1. 将策略绑定到目标证券和投资组合,并设置 CandleType 以匹配在 MT5 中使用的时间周期。
  2. 根据标的报价精度调整止盈/止损/移动止损的绝对距离(例如 5 位报价的外汇品种中 0.015 ≈ 150 点)。
  3. 结合账户规模校准 RiskFractionBaseVolume,确保头寸大小合理。
  4. 如需关闭移动止损,可将 TrailingStopOffset 设为 0。
  5. 日志中会输出 “Enter long”“Exit short” 等信息,对应原脚本的 Print 调试输出。

目录结构

API/2512_Up3x1/
├── CS/Up3x1DynamicSizingStrategy.cs      # 转换后的 C# 策略
├── README.md                # 英文说明
├── README_zh.md             # 中文说明(本文件)
└── README_ru.md             # 俄文说明

免责声明

量化策略交易具有高风险。本示例仅供学习研究,请在历史数据与模拟环境中充分验证后再考虑实际部署。

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>
/// EMA triple crossover strategy converted from the MetaTrader 5 "up3x1" expert.
/// Uses three exponential moving averages with optional stop loss, take profit and trailing logic.
/// Position size is reduced after losing trades similar to the original lot optimization routine.
/// </summary>
public class Up3x1DynamicSizingStrategy : Strategy
{
	private readonly StrategyParam<int> _fastPeriod;
	private readonly StrategyParam<int> _mediumPeriod;
	private readonly StrategyParam<int> _slowPeriod;
	private readonly StrategyParam<decimal> _takeProfitOffset;
	private readonly StrategyParam<decimal> _stopLossOffset;
	private readonly StrategyParam<decimal> _trailingStopOffset;
	private readonly StrategyParam<decimal> _baseVolume;
	private readonly StrategyParam<decimal> _riskFraction;
	private readonly StrategyParam<DataType> _candleType;

	private ExponentialMovingAverage _fastEma;
	private ExponentialMovingAverage _mediumEma;
	private ExponentialMovingAverage _slowEma;

	private bool _hasPrevValues;
	private decimal _prevFast;
	private decimal _prevMedium;
	private decimal _prevSlow;

	private decimal _entryPrice;
	private decimal _highestPrice;
	private decimal _lowestPrice;

	private int _losses;

	/// <summary>
	/// Fast EMA period.
	/// </summary>
	public int FastPeriod
	{
		get => _fastPeriod.Value;
		set => _fastPeriod.Value = value;
	}

	/// <summary>
	/// Medium EMA period.
	/// </summary>
	public int MediumPeriod
	{
		get => _mediumPeriod.Value;
		set => _mediumPeriod.Value = value;
	}

	/// <summary>
	/// Slow EMA period.
	/// </summary>
	public int SlowPeriod
	{
		get => _slowPeriod.Value;
		set => _slowPeriod.Value = value;
	}

	/// <summary>
	/// Absolute price distance for take profit.
	/// </summary>
	public decimal TakeProfitOffset
	{
		get => _takeProfitOffset.Value;
		set => _takeProfitOffset.Value = value;
	}

	/// <summary>
	/// Absolute price distance for stop loss.
	/// </summary>
	public decimal StopLossOffset
	{
		get => _stopLossOffset.Value;
		set => _stopLossOffset.Value = value;
	}

	/// <summary>
	/// Absolute trailing stop distance.
	/// </summary>
	public decimal TrailingStopOffset
	{
		get => _trailingStopOffset.Value;
		set => _trailingStopOffset.Value = value;
	}

	/// <summary>
	/// Base volume used when dynamic sizing cannot be calculated.
	/// </summary>
	public decimal BaseVolume
	{
		get => _baseVolume.Value;
		set => _baseVolume.Value = value;
	}

	/// <summary>
	/// Fraction of portfolio value used for dynamic position sizing.
	/// </summary>
	public decimal RiskFraction
	{
		get => _riskFraction.Value;
		set => _riskFraction.Value = value;
	}

	/// <summary>
	/// Candle type to subscribe.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	/// <summary>
	/// Initializes strategy parameters.
	/// </summary>
	public Up3x1DynamicSizingStrategy()
	{
		_fastPeriod = Param(nameof(FastPeriod), 24)
			.SetGreaterThanZero()
			.SetDisplay("Fast EMA", "Period of the fastest EMA", "Indicators");

		_mediumPeriod = Param(nameof(MediumPeriod), 60)
			.SetGreaterThanZero()
			.SetDisplay("Medium EMA", "Period of the middle EMA", "Indicators");

		_slowPeriod = Param(nameof(SlowPeriod), 120)
			.SetGreaterThanZero()
			.SetDisplay("Slow EMA", "Period of the slowest EMA", "Indicators");

		_takeProfitOffset = Param(nameof(TakeProfitOffset), 0.015m)
			.SetDisplay("Take Profit", "Absolute take profit distance in price units", "Risk");

		_stopLossOffset = Param(nameof(StopLossOffset), 0.01m)
			.SetDisplay("Stop Loss", "Absolute stop loss distance in price units", "Risk");

		_trailingStopOffset = Param(nameof(TrailingStopOffset), 0.004m)
			.SetDisplay("Trailing", "Trailing stop distance that follows price", "Risk");

		_baseVolume = Param(nameof(BaseVolume), 0.1m)
			.SetDisplay("Base Volume", "Fallback trade volume if dynamic sizing fails", "Money Management");

		_riskFraction = Param(nameof(RiskFraction), 0.02m)
			.SetDisplay("Risk Fraction", "Fraction of portfolio value used for sizing", "Money Management");

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

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

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

		Volume = BaseVolume;
		ResetState();
		_losses = 0;
		_hasPrevValues = false;
		_prevFast = 0m;
		_prevMedium = 0m;
		_prevSlow = 0m;
	}

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

		Volume = BaseVolume;

		_fastEma = new EMA
		{
			Length = FastPeriod
		};

		_mediumEma = new EMA
		{
			Length = MediumPeriod
		};

		_slowEma = new EMA
		{
			Length = SlowPeriod
		};

		var subscription = SubscribeCandles(CandleType);

		subscription
			.Bind(_fastEma, _mediumEma, _slowEma, ProcessCandle)
			.Start();

		StartProtection(null, null);
	}

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

		if (!_fastEma.IsFormed || !_mediumEma.IsFormed || !_slowEma.IsFormed)
			return;

		if (!_hasPrevValues)
		{
			_prevFast = fastValue;
			_prevMedium = mediumValue;
			_prevSlow = slowValue;
			_hasPrevValues = true;
			return;
		}

		if (!IsFormedAndOnlineAndAllowTrading())
		{
			_prevFast = fastValue;
			_prevMedium = mediumValue;
			_prevSlow = slowValue;
			return;
		}

		if (Position > 0)
		{
			if (TryHandleLongExit(candle, fastValue, mediumValue, slowValue))
			{
				_prevFast = fastValue;
				_prevMedium = mediumValue;
				_prevSlow = slowValue;
				return;
			}
		}
		else if (Position < 0)
		{
			if (TryHandleShortExit(candle, fastValue, mediumValue, slowValue))
			{
				_prevFast = fastValue;
				_prevMedium = mediumValue;
				_prevSlow = slowValue;
				return;
			}
		}
		else
		{
			var bullishSetup = _prevFast < _prevMedium && _prevMedium < _prevSlow && mediumValue < fastValue && fastValue < slowValue;
			var bearishSetup = _prevFast > _prevMedium && _prevMedium > _prevSlow && mediumValue > fastValue && fastValue > slowValue;

			if (bullishSetup)
			{
				TryEnterLong(candle);
			}
			else if (bearishSetup)
			{
				TryEnterShort(candle);
			}
		}

		_prevFast = fastValue;
		_prevMedium = mediumValue;
		_prevSlow = slowValue;
	}

	private void TryEnterLong(ICandleMessage candle)
	{
		var volume = CalculateOrderVolume();

		if (volume <= 0m)
		{
			LogInfo("Skipped long entry because calculated volume is below minimum.");
			return;
		}

		BuyMarket();

		_entryPrice = candle.ClosePrice;
		_highestPrice = candle.HighPrice;
		_lowestPrice = candle.LowPrice;

		LogInfo($"Enter long at {candle.ClosePrice} with volume {volume}. Loss counter: {_losses}.");
	}

	private void TryEnterShort(ICandleMessage candle)
	{
		var volume = CalculateOrderVolume();

		if (volume <= 0m)
		{
			LogInfo("Skipped short entry because calculated volume is below minimum.");
			return;
		}

		SellMarket();

		_entryPrice = candle.ClosePrice;
		_highestPrice = candle.HighPrice;
		_lowestPrice = candle.LowPrice;

		LogInfo($"Enter short at {candle.ClosePrice} with volume {volume}. Loss counter: {_losses}.");
	}

	private bool TryHandleLongExit(ICandleMessage candle, decimal fastValue, decimal mediumValue, decimal slowValue)
	{
		if (_entryPrice <= 0m)
			return false;

		var exitPrice = 0m;
		var reason = string.Empty;

		if (TakeProfitOffset > 0m)
		{
			var target = _entryPrice + TakeProfitOffset;
			if (candle.HighPrice >= target)
			{
				exitPrice = target;
				reason = "Take profit reached";
			}
		}

		if (exitPrice == 0m && StopLossOffset > 0m)
		{
			var stop = _entryPrice - StopLossOffset;
			if (candle.LowPrice <= stop)
			{
				exitPrice = stop;
				reason = "Stop loss triggered";
			}
		}

		_highestPrice = candle.HighPrice > _highestPrice ? candle.HighPrice : _highestPrice;

		if (exitPrice == 0m && TrailingStopOffset > 0m && _highestPrice - _entryPrice > TrailingStopOffset)
		{
			var trail = _highestPrice - TrailingStopOffset;
			if (candle.LowPrice <= trail)
			{
				exitPrice = trail;
				reason = "Trailing stop hit";
			}
		}

		if (exitPrice == 0m)
		{
			var reversal = _prevFast > _prevMedium && _prevMedium > _prevSlow && slowValue < fastValue && fastValue < mediumValue;
			if (reversal)
			{
				exitPrice = candle.ClosePrice;
				reason = "EMA reversal";
			}
		}

		if (exitPrice == 0m)
			return false;

		ExitPosition(exitPrice, reason);
		return true;
	}

	private bool TryHandleShortExit(ICandleMessage candle, decimal fastValue, decimal mediumValue, decimal slowValue)
	{
		if (_entryPrice <= 0m)
			return false;

		var exitPrice = 0m;
		var reason = string.Empty;

		if (TakeProfitOffset > 0m)
		{
			var target = _entryPrice - TakeProfitOffset;
			if (candle.LowPrice <= target)
			{
				exitPrice = target;
				reason = "Take profit reached";
			}
		}

		if (exitPrice == 0m && StopLossOffset > 0m)
		{
			var stop = _entryPrice + StopLossOffset;
			if (candle.HighPrice >= stop)
			{
				exitPrice = stop;
				reason = "Stop loss triggered";
			}
		}

		_lowestPrice = _lowestPrice == 0m || candle.LowPrice < _lowestPrice ? candle.LowPrice : _lowestPrice;

		if (exitPrice == 0m && TrailingStopOffset > 0m && _entryPrice - _lowestPrice > TrailingStopOffset)
		{
			var trail = _lowestPrice + TrailingStopOffset;
			if (candle.HighPrice >= trail)
			{
				exitPrice = trail;
				reason = "Trailing stop hit";
			}
		}

		if (exitPrice == 0m)
		{
			var reversal = _prevFast > _prevMedium && _prevMedium > _prevSlow && slowValue < fastValue && fastValue < mediumValue;
			if (reversal)
			{
				exitPrice = candle.ClosePrice;
				reason = "EMA reversal";
			}
		}

		if (exitPrice == 0m)
			return false;

		ExitPosition(exitPrice, reason);
		return true;
	}

	private void ExitPosition(decimal exitPrice, string reason)
	{
		var isLong = Position > 0;
		var volume = Math.Abs(Position);

		if (volume <= 0m)
			return;

		var pnl = isLong
			? (exitPrice - _entryPrice) * volume
			: (_entryPrice - exitPrice) * volume;

		if (isLong)
		{
			SellMarket();
		}
		else
		{
			BuyMarket();
		}

		LogInfo($"Exit {(isLong ? "long" : "short")} at {exitPrice} because {reason}. Approx PnL: {pnl}.");

		if (pnl < 0m)
			_losses++;

		ResetState();
	}

	private decimal CalculateOrderVolume()
	{
		var volume = 0m;

		var portfolioValue = Portfolio?.CurrentValue ?? 0m;

		if (portfolioValue > 0m && RiskFraction > 0m)
			volume = portfolioValue * RiskFraction / 1000m;

		if (volume <= 0m)
			volume = BaseVolume;

		if (_losses > 1)
		{
			var reduction = volume * _losses / 3m;
			volume -= reduction;

			if (volume <= 0m)
				volume = BaseVolume;
		}

		volume = AdjustVolumeToInstrument(volume);

		return volume;
	}

	private decimal AdjustVolumeToInstrument(decimal volume)
	{
		var security = Security;

		if (security == null)
			return volume;

		var step = security.VolumeStep ?? 0m;

		if (step > 0m)
			volume = Math.Floor(volume / step) * step;

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

		var maxVolume = security.MaxVolume ?? 0m;
		if (maxVolume > 0m && volume > maxVolume)
			volume = maxVolume;

		return volume;
	}

	private void ResetState()
	{
		_entryPrice = 0m;
		_highestPrice = 0m;
		_lowestPrice = 0m;
	}
}