在 GitHub 上查看

Dealers Trade v7.51 RIVOT(C#)

摘要

Dealers Trade v7.51 最初是 MetaTrader 4 平台上的 Dealers_Trade_v_7.51_RIVOT.mq4 专家顾问,属于典型的基于枢轴的网格马丁策略。本移植版本在 StockSharp 平台上重建了核心思想:利用经典枢轴价位与浮动枢轴价位之间的差异判定方向,只要价格向不利方向回撤到设定的点差,就逐步加仓并放大手数,同时通过止损、止盈与跟踪止损控制风险。

交易逻辑

  1. 枢轴体系

    • 每根完成的 K 线都会计算两条参考线:
      • 经典枢轴 P = (上一根最高价 + 上一根最低价 + 上一根收盘价 + 当前开盘价) / 4
      • 浮动枢轴 FLP = (当前最高价 + 当前最低价 + 当前收盘价) / 3
    • PFLP 之间的点差超过 GapThreshold 时,策略才允许在本根 K 线进行交易。
  2. 方向判断

    • 收盘价同时高于两条枢轴且满足点差过滤时,建立 多头 偏向;
    • 收盘价同时低于两条枢轴且满足点差过滤时,建立 空头 偏向;
    • 在当前加仓序列关闭之前,偏向保持不变。
  3. 加仓方式

    • 同一时间仅允许存在一个网格序列;
    • 第一笔订单在偏向确认后立即执行;
    • 只有当价格相对上一次成交向不利方向回撤至少 PipDistance 个点时,才会触发下一笔加仓;
    • 新订单手数按 VolumeMultiplier 倍数递增,但不会超过 MaxVolume
    • 序列中的订单数量受 MaxTrades 限制。
  4. 风险控制

    • 以加权平均开仓价为基准,StopLoss 点的浮动止损触发全仓平仓;
    • 价格向有利方向运行 TakeProfit 点时,触发统一止盈;
    • 如果 TrailingStop 大于零,策略会在盈利扩大的同时移动跟踪止损,锁定部分收益。
  5. 重置条件

    • 当仓位被止损、止盈、跟踪止损或手动平仓后,加仓计数与方向状态都会被重置。

参数

参数 默认值 说明
Volume 1 首笔订单的基础手数。
MaxTrades 5 同一序列内最多允许的订单数量。
PipDistance 4 触发下一笔加仓所需的最小不利点数。
TakeProfit 15 相对平均开仓价的整体止盈距离。
StopLoss 90 相对平均开仓价的整体止损距离。
TrailingStop 15 跟踪止损与价格之间保持的点差,设为 0 表示关闭。
VolumeMultiplier 1.5 每次加仓后手数放大的倍数。
MaxVolume 5 单笔订单手数的上限。
GapThreshold 7 启动交易所需的最小枢轴点差。
CandleType 15 分钟时间框架 计算所用的 K 线类型。

所有参数均通过 StrategyParam<T> 声明,可在 StockSharp Designer 或回测环境中直接优化。

使用提示

  • 策略完全依赖 K 线数据,无需逐笔报价;请确保数据源能够提供所选时间框架的蜡烛序列。
  • StockSharp 默认采用净头寸模型,代码会维护内部的加权平均价来模拟 MT4 的多笔订单效果。
  • 如果图表区域可用,程序会绘制 PivotFloatingPivot 两条参考线以便观察。
  • 策略不会在持仓过程中反向开仓,只有在当前序列结束后才会评估新的方向。

与 MQL 版本的差异

  • 原始 EA 会在 MT4 图表上绘制标签与文字提示,移植版本仅保留交易逻辑,并以图表线条代替视觉提示。
  • 与账户余额、魔术号、品种点值相关的保护逻辑在 StockSharp 中不再需要,因此被删除。
  • MT4 中基于 Ask == tp 的精确出场在此实现中转换为对 K 线价格的比较。
  • 下单与平仓统一使用 BuyMarket / SellMarket,并在 K 线更新时执行风控,不再遍历 MT4 的订单列表。

最佳实践

  • 在真实交易前务必进行历史回测或模拟盘测试,并考虑点差和手续费的影响。
  • 对于波动性较大的品种,可适当降低 VolumeMultiplierMaxTrades 以控制回撤。
  • 可根据需要调整 CandleType(例如 M15、H1 等)以契合原始策略的使用场景。

文件

  • CS/DealersTradeV751RivotStrategy.cs —— C# 策略实现。
  • README.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>
/// Dealers Trade v7.51 strategy ported from MetaTrader 4 implementation.
/// Builds directional bias from classic pivot and floating pivot levels
/// and scales into the bias when price retraces by a fixed pip distance.
/// Applies martingale-style position sizing with configurable stop-loss,
/// take-profit, and trailing-stop management.
/// </summary>
public class DealersTradeV751RivotStrategy : Strategy
{
	private readonly StrategyParam<int> _maxTrades;
	private readonly StrategyParam<decimal> _pipDistance;
	private readonly StrategyParam<decimal> _takeProfit;
	private readonly StrategyParam<decimal> _stopLoss;
	private readonly StrategyParam<decimal> _trailingStop;
	private readonly StrategyParam<decimal> _volumeMultiplier;
	private readonly StrategyParam<decimal> _maxVolume;
	private readonly StrategyParam<decimal> _gapThreshold;
	private readonly StrategyParam<DataType> _candleType;

	private ICandleMessage _previousCandle;
	private decimal _pivotLevel;
	private decimal _floatingPivot;
	private decimal _gapInPips;
	private decimal _lastEntryPrice;
	private decimal _averageEntryPrice;
	private decimal? _trailingStopLevel;
	private int _direction; // -1 short, 0 neutral, 1 long
	private int _entriesInSeries;

	/// <summary>
	/// Maximum number of entries allowed in one scaling series.
	/// </summary>
	public int MaxTrades
	{
		get => _maxTrades.Value;
		set => _maxTrades.Value = value;
	}

	/// <summary>
	/// Distance in pips between martingale entries.
	/// </summary>
	public decimal PipDistance
	{
		get => _pipDistance.Value;
		set => _pipDistance.Value = value;
	}

	/// <summary>
	/// Take-profit distance in pips.
	/// </summary>
	public decimal TakeProfit
	{
		get => _takeProfit.Value;
		set => _takeProfit.Value = value;
	}

	/// <summary>
	/// Stop-loss distance in pips.
	/// </summary>
	public decimal StopLoss
	{
		get => _stopLoss.Value;
		set => _stopLoss.Value = value;
	}

	/// <summary>
	/// Trailing-stop distance in pips.
	/// </summary>
	public decimal TrailingStop
	{
		get => _trailingStop.Value;
		set => _trailingStop.Value = value;
	}

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

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

	/// <summary>
	/// Minimum pivot gap in pips required to activate the bias.
	/// </summary>
	public decimal GapThreshold
	{
		get => _gapThreshold.Value;
		set => _gapThreshold.Value = value;
	}

	/// <summary>
	/// Type of candles used for pivot calculations.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	/// <summary>
	/// Initializes strategy parameters.
	/// </summary>
	public DealersTradeV751RivotStrategy()
	{
		_maxTrades = Param(nameof(MaxTrades), 2)
		.SetGreaterThanZero()
		.SetDisplay("Max Trades", "Maximum number of martingale entries", "Position Sizing")
		
		.SetOptimize(1, 10, 1);

		_pipDistance = Param(nameof(PipDistance), 10m)
		.SetGreaterThanZero()
		.SetDisplay("Pip Distance", "Distance between averaged entries in pips", "Position Sizing")
		
		.SetOptimize(2m, 15m, 1m);

		_takeProfit = Param(nameof(TakeProfit), 15m)
		.SetGreaterThanZero()
		.SetDisplay("Take Profit", "Take-profit distance in pips", "Risk Management")
		
		.SetOptimize(5m, 50m, 5m);

		_stopLoss = Param(nameof(StopLoss), 90m)
		.SetGreaterThanZero()
		.SetDisplay("Stop Loss", "Stop-loss distance in pips", "Risk Management")
		
		.SetOptimize(30m, 200m, 10m);

		_trailingStop = Param(nameof(TrailingStop), 15m)
		.SetGreaterThanZero()
		.SetDisplay("Trailing Stop", "Trailing-stop distance in pips", "Risk Management")
		
		.SetOptimize(5m, 40m, 5m);

		_volumeMultiplier = Param(nameof(VolumeMultiplier), 1.5m)
		.SetGreaterThanZero()
		.SetDisplay("Volume Multiplier", "Multiplier applied after each new entry", "Position Sizing")
		
		.SetOptimize(1.1m, 3m, 0.1m);

		_maxVolume = Param(nameof(MaxVolume), 5m)
		.SetGreaterThanZero()
		.SetDisplay("Max Volume", "Upper limit for single-entry volume", "Position Sizing");

		_gapThreshold = Param(nameof(GapThreshold), 15m)
		.SetGreaterThanZero()
		.SetDisplay("Gap Threshold", "Minimal pivot gap required to enable trading", "Signal")
		
		.SetOptimize(3m, 15m, 1m);

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
		.SetDisplay("Candle Type", "Type of candles used for pivot calculations", "Signal");
	}

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

	/// <inheritdoc />
	protected override void OnReseted()
	{
		base.OnReseted();
		ResetSeries();
		_previousCandle = null;
		_pivotLevel = 0m;
		_floatingPivot = 0m;
		_gapInPips = 0m;
	}

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

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

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

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

		if (Position == 0m)
		{
			// Reset martingale state once the position is closed externally.
			ResetSeries();
		}
	}

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

		if (_previousCandle == null)
		{
			_previousCandle = candle;
			return;
		}

		UpdatePivots(candle);

		if (Position == 0m && _entriesInSeries > 0)
		{
			// Force reset when no exposure remains but scaling data still exists.
			ResetSeries();
		}

		if (_entriesInSeries > 0)
		{
			ManageRisk(candle.ClosePrice);
		}

		if (_entriesInSeries >= MaxTrades)
		{
			_previousCandle = candle;
			return;
		}

		if (_direction == 0)
		{
			EvaluateDirection(candle);
		}

		TryEnter(candle);

		_previousCandle = candle;
	}

	private void UpdatePivots(ICandleMessage candle)
	{
		var step = GetPriceStep();
		_pivotLevel = (_previousCandle!.HighPrice + _previousCandle.LowPrice + _previousCandle.ClosePrice + candle.OpenPrice) / 4m;
		_floatingPivot = (candle.HighPrice + candle.LowPrice + candle.ClosePrice) / 3m;
		_gapInPips = step == 0m ? 0m : Math.Abs(_pivotLevel - _floatingPivot) / step;
	}

	private void EvaluateDirection(ICandleMessage candle)
	{
		var price = candle.ClosePrice;

		if (price > _pivotLevel && price > _floatingPivot && _gapInPips >= GapThreshold)
		{
			_direction = 1;
			LogInfo($"Bias switched to long. Pivot={_pivotLevel:F5}, Floating={_floatingPivot:F5}, Gap={_gapInPips:F2} pips.");
		}
		else if (price < _pivotLevel && price < _floatingPivot && _gapInPips >= GapThreshold)
		{
			_direction = -1;
			LogInfo($"Bias switched to short. Pivot={_pivotLevel:F5}, Floating={_floatingPivot:F5}, Gap={_gapInPips:F2} pips.");
		}
	}

	private void TryEnter(ICandleMessage candle)
	{
		if (_direction == 0)
		return;

		var price = candle.ClosePrice;
		var step = GetPriceStep();
		var distance = PipDistance * step;

		if (_direction > 0)
		{
			if (_entriesInSeries == 0 || (_lastEntryPrice - price) >= distance)
			{
				EnterLong(price);
			}
		}
		else
		{
			if (_entriesInSeries == 0 || (price - _lastEntryPrice) >= distance)
			{
				EnterShort(price);
			}
		}
	}

	private void EnterLong(decimal price)
	{
		var volume = CalculateNextVolume();
		_lastEntryPrice = price;
		_averageEntryPrice = UpdateAveragePrice(price, volume, true);
		_entriesInSeries++;
		LogInfo($"Opening long entry #{_entriesInSeries} at {price:F5} with volume {volume}.");
		BuyMarket(volume);
	}

	private void EnterShort(decimal price)
	{
		var volume = CalculateNextVolume();
		_lastEntryPrice = price;
		_averageEntryPrice = UpdateAveragePrice(price, volume, false);
		_entriesInSeries++;
		LogInfo($"Opening short entry #{_entriesInSeries} at {price:F5} with volume {volume}.");
		SellMarket(volume);
	}

	private decimal CalculateNextVolume()
	{
		var volume = Volume;

		for (var i = 0; i < _entriesInSeries; i++)
		{
			volume *= VolumeMultiplier;
			if (volume >= MaxVolume)
			{
				volume = MaxVolume;
				break;
			}
		}

		var volumeStep = Security?.VolumeStep ?? 0.01m;
		if (volumeStep > 0m)
		{
			volume = Math.Ceiling(volume / volumeStep) * volumeStep;
		}

		return volume;
	}

	private decimal UpdateAveragePrice(decimal price, decimal volume, bool isLong)
	{
		var existingVolume = Math.Abs(Position);
		var side = isLong ? 1m : -1m;

		if (existingVolume <= 0m)
		{
			return price;
		}

		var totalVolume = existingVolume + volume;
		var weightedAverage = ((_averageEntryPrice * existingVolume * side) + (price * volume)) / totalVolume;
		return Math.Abs(weightedAverage);
	}

	private void ManageRisk(decimal price)
	{
		if (_entriesInSeries == 0)
		{
			_trailingStopLevel = null;
			return;
		}

		var step = GetPriceStep();
		var stopDistance = StopLoss * step;
		var takeDistance = TakeProfit * step;
		var trailingDistance = TrailingStop * step;

		if (_direction > 0)
		{
			var lossLevel = _averageEntryPrice - stopDistance;
			var profitLevel = _averageEntryPrice + takeDistance;

			if (price <= lossLevel)
			{
				LogInfo($"Long stop-loss triggered at {price:F5}. Average entry {_averageEntryPrice:F5}.");
				SellMarket(Math.Abs(Position));
				ResetSeries();
				return;
			}

			if (price >= profitLevel)
			{
				LogInfo($"Long take-profit triggered at {price:F5}. Average entry {_averageEntryPrice:F5}.");
				SellMarket(Math.Abs(Position));
				ResetSeries();
				return;
			}

			if (TrailingStop > 0m)
			{
				var candidate = price - trailingDistance;
				if (_trailingStopLevel == null || candidate > _trailingStopLevel)
				{
					_trailingStopLevel = candidate;
				}

				if (_trailingStopLevel != null && price <= _trailingStopLevel)
				{
					LogInfo($"Long trailing stop activated at {price:F5}.");
					SellMarket(Math.Abs(Position));
					ResetSeries();
				}
			}
		}
		else if (_direction < 0)
		{
			var lossLevel = _averageEntryPrice + stopDistance;
			var profitLevel = _averageEntryPrice - takeDistance;

			if (price >= lossLevel)
			{
				LogInfo($"Short stop-loss triggered at {price:F5}. Average entry {_averageEntryPrice:F5}.");
				BuyMarket(Math.Abs(Position));
				ResetSeries();
				return;
			}

			if (price <= profitLevel)
			{
				LogInfo($"Short take-profit triggered at {price:F5}. Average entry {_averageEntryPrice:F5}.");
				BuyMarket(Math.Abs(Position));
				ResetSeries();
				return;
			}

			if (TrailingStop > 0m)
			{
				var candidate = price + trailingDistance;
				if (_trailingStopLevel == null || candidate < _trailingStopLevel)
				{
					_trailingStopLevel = candidate;
				}

				if (_trailingStopLevel != null && price >= _trailingStopLevel)
				{
					LogInfo($"Short trailing stop activated at {price:F5}.");
					BuyMarket(Math.Abs(Position));
					ResetSeries();
				}
			}
		}
	}

	private decimal GetPriceStep()
	{
		var step = Security?.PriceStep ?? 0m;
		if (step == 0m)
		{
			// Fallback to four decimal places when instrument metadata is unknown.
			step = 0.0001m;
		}
		return step;
	}

	private void ResetSeries()
	{
		_direction = 0;
		_entriesInSeries = 0;
		_lastEntryPrice = 0m;
		_averageEntryPrice = 0m;
		_trailingStopLevel = null;
	}
}