在 GitHub 上查看

Vector 策略(来自 MT4 的移植)

本目录提供了 MetaTrader 4 智能交易系统 Vector(源码位于 MQL/8305/Vector.mq4)在 StockSharp 高级 API 上的移植版本。策略可同时分析四个主要外汇品种:EURUSD(主品种)、GBPUSD、USDCHF、USDJPY,并在出现一致的趋势信号时同步开仓。

交易逻辑

  1. 平滑移动平均线(SMMA):每个品种都计算两条 SMMA,周期分别为 3 和 7,使用所选交易周期(默认 15 分钟)的中价。
  2. 向量化趋势过滤:对所有可用品种的快慢均线差值进行求和。结果为正表示篮子整体偏多,为负表示整体偏空。
  3. 入场条件:仅在以下情况通过市价单开仓或反手:
    • 总体趋势为正且该品种的快线位于慢线之上 → 做多。
    • 总体趋势为负且快线位于慢线之下 → 做空。
  4. H4 波动确定止盈:为每个品种额外订阅 4 小时蜡烛,取上一根 H4 蜡烛的价格区间并除以 5,最多 13 点,作为该品种的目标利润,与原始脚本保持一致。
  5. 账户层面的止盈/止损:参数 TakeProfitPercentMaxDrawdownPercent 控制整体权益的止盈和最大回撤,一旦触发便立即平掉所有仓位。

与原版的差异

  • 采用 StockSharp 的 SubscribeCandles().Bind(...) 高级模式处理数据,不再循环访问历史报价。
  • GBPUSD / USDCHF / USDJPY 被设计为 可选参数,如果不填只交易主品种。
  • 原脚本根据保证金动态计算手数的逻辑被 BaseVolume 参数取代,系统会自动根据 VolumeStepMinVolumeMaxVolume 进行归一化。
  • 入场价格通过 OnNewMyTrade 事件跟踪,符合本项目禁止直接访问指标历史值的规范。

参数说明

名称 默认值 说明
CandleType TimeSpan.FromMinutes(15) 计算 SMMA 以及入场信号的交易周期。
RangeCandleType TimeSpan.FromHours(4) 计算止盈目标的高阶周期。
SecondSecurity null 备用品种(通常为 GBPUSD)。
ThirdSecurity null 备用品种(通常为 USDCHF)。
FourthSecurity null 备用品种(通常为 USDJPY)。
BaseVolume 1 每笔订单的基础数量,会自动匹配交易所要求。
TakeProfitPercent 0.5 当账户盈利达到该百分比时全部平仓。
MaxDrawdownPercent 30 账户回撤超过该百分比时全部平仓。

使用建议

  • 启动前请为所有参与交易的品种配置相同的连接器与投资组合。
  • 确认行情源能够提供交易周期与 H4 周期的蜡烛数据。
  • 未填写的品种会被自动忽略,向量求和只使用仍然可用的品种。
  • 平仓始终通过市价单完成,以匹配原 MT4 策略的行为。

文件列表

  • CS/VectorStrategy.cs:C# 实现,完全基于 StockSharp 高级 API。
  • README.mdREADME_ru.mdREADME_zh.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>
/// Port of the MetaTrader 4 expert advisor "Vector".
/// The strategy trades up to four correlated forex pairs using smoothed moving averages.
/// </summary>
public class VectorBasketTrendStrategy : Strategy
{
	private readonly StrategyParam<Security> _secondSecurity;
	private readonly StrategyParam<Security> _thirdSecurity;
	private readonly StrategyParam<Security> _fourthSecurity;
	private readonly StrategyParam<DataType> _candleType;
	private readonly StrategyParam<DataType> _rangeCandleType;
	private readonly StrategyParam<decimal> _baseVolume;
	private readonly StrategyParam<decimal> _takeProfitPercent;
	private readonly StrategyParam<decimal> _maxDrawdownPercent;

	private readonly Dictionary<Security, InstrumentContext> _contexts = new();
	private readonly Dictionary<Security, decimal> _lastPositions = new();

	private decimal _initialBalance;
	private bool _profitTargetTriggered;
	private bool _drawdownTriggered;

	/// <summary>
	/// Secondary instrument representing GBPUSD in the original script.
	/// </summary>
	public Security SecondSecurity
	{
		get => _secondSecurity.Value;
		set => _secondSecurity.Value = value;
	}

	/// <summary>
	/// Third instrument representing USDCHF in the original script.
	/// </summary>
	public Security ThirdSecurity
	{
		get => _thirdSecurity.Value;
		set => _thirdSecurity.Value = value;
	}

	/// <summary>
	/// Fourth instrument representing USDJPY in the original script.
	/// </summary>
	public Security FourthSecurity
	{
		get => _fourthSecurity.Value;
		set => _fourthSecurity.Value = value;
	}

	/// <summary>
	/// Trading candle type used to compute the smoothed moving averages.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	/// <summary>
	/// Higher timeframe candle type that defines the profit target distance.
	/// </summary>
	public DataType RangeCandleType
	{
		get => _rangeCandleType.Value;
		set => _rangeCandleType.Value = value;
	}

	/// <summary>
	/// Base volume requested for each trade.
	/// </summary>
	public decimal BaseVolume
	{
		get => _baseVolume.Value;
		set => _baseVolume.Value = value;
	}

	/// <summary>
	/// Percentage profit target that closes every open position.
	/// </summary>
	public decimal TakeProfitPercent
	{
		get => _takeProfitPercent.Value;
		set => _takeProfitPercent.Value = value;
	}

	/// <summary>
	/// Maximum tolerated equity drawdown expressed in percent.
	/// </summary>
	public decimal MaxDrawdownPercent
	{
		get => _maxDrawdownPercent.Value;
		set => _maxDrawdownPercent.Value = value;
	}

	/// <summary>
/// Initializes a new instance of the <see cref="VectorBasketTrendStrategy"/> class.
/// </summary>
public VectorBasketTrendStrategy()
	{
		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(5).TimeFrame())
			.SetDisplay("Candle Type", "Timeframe for MA calculation", "General");

		_rangeCandleType = Param(nameof(RangeCandleType), TimeSpan.FromHours(4).TimeFrame())
			.SetDisplay("Range Candle Type", "Higher timeframe for pip target", "General");

		_secondSecurity = Param<Security>(nameof(SecondSecurity))
			.SetDisplay("Second Security", "Optional correlated instrument", "General");

		_thirdSecurity = Param<Security>(nameof(ThirdSecurity))
			.SetDisplay("Third Security", "Optional correlated instrument", "General");

		_fourthSecurity = Param<Security>(nameof(FourthSecurity))
			.SetDisplay("Fourth Security", "Optional correlated instrument", "General");

		_baseVolume = Param(nameof(BaseVolume), 1m)
			.SetDisplay("Base Volume", "Requested volume for each trade", "Trading");

		_takeProfitPercent = Param(nameof(TakeProfitPercent), 0.5m)
			.SetDisplay("Account Take Profit %", "Equity gain that forces a global exit", "Risk");

		_maxDrawdownPercent = Param(nameof(MaxDrawdownPercent), 30m)
			.SetDisplay("Max Drawdown %", "Equity loss that forces a global exit", "Risk");
	}

	/// <inheritdoc />
	public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
	{
		foreach (var security in EnumerateSecurities())
		{
			yield return (security, CandleType);
			yield return (security, RangeCandleType);
		}
	}

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

		_contexts.Clear();
		_lastPositions.Clear();
		_initialBalance = 0m;
		_profitTargetTriggered = false;
		_drawdownTriggered = false;
	}

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

		if (Security == null)
			throw new InvalidOperationException("Primary security is not specified.");

		_initialBalance = Portfolio?.CurrentValue ?? 0m;
		CreateContext(Security);
		CreateContext(SecondSecurity);
		CreateContext(ThirdSecurity);
		CreateContext(FourthSecurity);
	}

	/// <inheritdoc />
	protected override void OnOwnTradeReceived(MyTrade trade)
	{
		base.OnOwnTradeReceived(trade);

		var security = trade.Order.Security;
		if (security == null)
			return;

		if (!_contexts.TryGetValue(security, out var context))
			return;

		var position = GetPositionVolume(security);
		_lastPositions.TryGetValue(security, out var previousPosition);
		var tradeVolume = trade.Trade.Volume;

		if (previousPosition == 0m && position != 0m)
		{
			context.EntryPrice = trade.Trade.Price;
		}
		else if (position == 0m)
		{
			context.EntryPrice = null;
		}
		else if (Math.Sign((double)previousPosition) != Math.Sign((double)position))
		{
			context.EntryPrice = trade.Trade.Price;
		}
		else if (position > 0m && trade.Order.Side == Sides.Buy)
		{
			UpdateAverageEntry(context, previousPosition, position, tradeVolume, trade.Trade.Price);
		}
		else if (position < 0m && trade.Order.Side == Sides.Sell)
		{
			UpdateAverageEntry(context, Math.Abs(previousPosition), Math.Abs(position), tradeVolume, trade.Trade.Price);
		}

		_lastPositions[security] = position;
	}

	private void CreateContext(Security security)
	{
		if (security == null)
			return;

		if (_contexts.ContainsKey(security))
			return;

		var fast = new SmoothedMovingAverage { Length = 3 };
		var slow = new SmoothedMovingAverage { Length = 7 };
		var context = new InstrumentContext(security, fast, slow, GetPipSize(security));
		_contexts.Add(security, context);
		_lastPositions[security] = GetPositionVolume(security);

		var subscription = SubscribeCandles(CandleType, security: security);
		subscription
			.Bind(fast, slow, (candle, fastValue, slowValue) => ProcessInstrumentCandle(context, candle, fastValue, slowValue))
			.Start();

		var rangeSubscription = SubscribeCandles(RangeCandleType, security: security);
		rangeSubscription
			.Bind(candle => ProcessRangeCandle(context, candle))
			.Start();
	}

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

		context.FastValue = fastValue;
		context.SlowValue = slowValue;
		context.LastClose = candle.ClosePrice;

		EvaluateExits(context);
		EvaluateEntries();
		CheckGlobalRisk();
	}

	private void ProcessRangeCandle(InstrumentContext context, ICandleMessage candle)
	{
		if (candle.State != CandleStates.Finished)
			return;

		var range = candle.HighPrice - candle.LowPrice;
		if (range <= 0m || context.PipSize <= 0m)
		{
			context.TargetDistance = 0m;
			return;
		}

		var pipRange = range / context.PipSize;
		var targetPips = Math.Min(13m, pipRange / 5m);
		context.TargetDistance = targetPips * context.PipSize;
	}

	private void EvaluateEntries()
	{
		if (!IsFormedAndOnlineAndAllowTrading())
			return;

		var totalTrend = 0m;
		var readyPairs = 0;

		foreach (var context in _contexts.Values)
		{
			if (context.FastValue is decimal fast && context.SlowValue is decimal slow)
			{
				totalTrend += fast - slow;
				readyPairs++;
			}
		}

		if (readyPairs == 0 || totalTrend == 0m)
			return;

		var bullish = totalTrend > 0m;

		foreach (var context in _contexts.Values)
		{
			if (context.FastValue is not decimal fast || context.SlowValue is not decimal slow)
				continue;

			var position = GetPositionVolume(context.Security);
			if (bullish)
			{
				if (fast > slow && position <= 0m)
					TryEnter(context, Sides.Buy, Math.Abs(position));
			}
			else
			{
				if (fast < slow && position >= 0m)
					TryEnter(context, Sides.Sell, Math.Abs(position));
			}
		}
	}

	private void EvaluateExits(InstrumentContext context)
	{
		if (context.EntryPrice is null || context.TargetDistance <= 0m || context.LastClose is null)
			return;

		var position = GetPositionVolume(context.Security);
		if (position == 0m)
			return;

		var entryPrice = context.EntryPrice.Value;
		var lastClose = context.LastClose.Value;
		var profit = lastClose - entryPrice;

		if (position > 0m && profit >= context.TargetDistance)
		{
			SellMarket(position, context.Security);
		}
		else if (position < 0m && -profit >= context.TargetDistance)
		{
			BuyMarket(Math.Abs(position), context.Security);
		}
	}

	private void TryEnter(InstrumentContext context, Sides side, decimal currentPosition)
	{
		var volume = NormalizeVolume(context.Security, BaseVolume);
		if (volume <= 0m)
			return;

		if (side == Sides.Buy)
		{
			if (currentPosition > 0m)
			{
				SellMarket(currentPosition, context.Security);
			}
			BuyMarket(volume, context.Security);
		}
		else
		{
			if (currentPosition > 0m)
			{
				BuyMarket(currentPosition, context.Security);
			}
			SellMarket(volume, context.Security);
		}
	}

	private decimal NormalizeVolume(Security security, decimal requestedVolume)
	{
		var step = security.VolumeStep ?? 0m;
		var min = security.MinVolume ?? 0m;
		var max = security.MaxVolume;
		var volume = requestedVolume;

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

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

		if (max != null && volume > max.Value)
			volume = max.Value;

		return volume;
	}

	private void CheckGlobalRisk()
	{
		if (Portfolio?.CurrentValue is not decimal currentValue || _initialBalance <= 0m)
			return;

		var profit = currentValue - _initialBalance;
		var profitThreshold = _initialBalance * (TakeProfitPercent / 100m);
		var lossThreshold = _initialBalance * (MaxDrawdownPercent / 100m);

		if (!_profitTargetTriggered && profitThreshold > 0m && profit >= profitThreshold)
		{
			_profitTargetTriggered = true;
			CloseAllPositions("Global profit target reached");
		}

		if (!_drawdownTriggered && lossThreshold > 0m && -profit >= lossThreshold)
		{
			_drawdownTriggered = true;
			CloseAllPositions("Global drawdown limit reached");
		}
	}

	private void CloseAllPositions(string reason)
	{
		foreach (var context in _contexts.Values)
		{
			var position = GetPositionVolume(context.Security);
			if (position > 0m)
			{
				SellMarket(position, context.Security);
			}
			else if (position < 0m)
			{
				BuyMarket(Math.Abs(position), context.Security);
			}
		}

		LogInfo(reason);
	}

	private void UpdateAverageEntry(InstrumentContext context, decimal previousPosition, decimal currentPosition, decimal tradeVolume, decimal tradePrice)
	{
		if (tradeVolume <= 0m || previousPosition <= 0m || currentPosition <= 0m)
		{
			context.EntryPrice ??= tradePrice;
			return;
		}

		if (context.EntryPrice is decimal existing)
		{
			context.EntryPrice = ((existing * previousPosition) + (tradePrice * tradeVolume)) / currentPosition;
		}
		else
		{
			context.EntryPrice = tradePrice;
		}
	}

	private IEnumerable<Security> EnumerateSecurities()
	{
		if (Security != null)
			yield return Security;
		if (SecondSecurity != null)
			yield return SecondSecurity;
		if (ThirdSecurity != null)
			yield return ThirdSecurity;
		if (FourthSecurity != null)
			yield return FourthSecurity;
	}

	private decimal GetPositionVolume(Security security)
	{
		return GetPositionValue(security, Portfolio) ?? 0m;
	}

	private static decimal GetPipSize(Security security)
	{
		var step = security.PriceStep;
		if (step == null || step == 0m)
			return 0.0001m;

		return step.Value;
	}

	private sealed class InstrumentContext
	{
		public InstrumentContext(Security security, SmoothedMovingAverage fast, SmoothedMovingAverage slow, decimal pipSize)
		{
			Security = security;
			Fast = fast;
			Slow = slow;
			PipSize = pipSize;
		}

		public Security Security { get; }
		public SmoothedMovingAverage Fast { get; }
		public SmoothedMovingAverage Slow { get; }
		public decimal PipSize { get; }
		public decimal? FastValue { get; set; }
		public decimal? SlowValue { get; set; }
		public decimal TargetDistance { get; set; }
		public decimal? EntryPrice { get; set; }
		public decimal? LastClose { get; set; }
	}
}