在 GitHub 上查看

Proper Bot 策略

概述

Proper Bot 策略 是从 MetaTrader 4 的 "Proper Bot" 智能交易系统移植而来的网格化交易模型。策略通过初始方向性头寸启动一个订单篮子,根据可配置的距离/手数映射逐步加仓,并结合时间、成交量与价格过滤器管理整个交易周期。该 C# 版本基于 StockSharp 的高级蜡烛订阅与指标接口实现。

工作原理

  1. 信号判定
    • 当启用 EMA 过滤器时,策略在选定的蜡烛序列上计算快、中、慢三条指数移动平均线。快线与慢线的交叉决定方向,中线用于过滤尚未确认的趋势信号。
    • 当关闭过滤器时,算法直接使用上一根已完成蜡烛的实体方向。
  2. 交易前过滤
    • 使用成交量的简单移动平均,只有当平均值高于阈值时才允许进场。
    • 交易仅在配置的会话起止时间内执行。
    • 价格高于 HighLevel 时阻止做多,低于 LowLevel 时阻止做空;极端突破这些边界会强制产生对应方向的信号。
  3. 网格扩张
    • 初始市价单使用 FirstVolume 参数。之后的加仓按照 GridMap 中的 "距离/手数" 对逐级执行,当价格相对当前方向回撤到指定距离时,按映射手数加仓。
    • 距离以交易品种的 PriceStep 转换为价格差;若未提供步长则使用 0.0001 作为默认值。
  4. 风险管理
    • 整个订单篮子共享基于加权平均持仓价的整体止盈与止损距离。
    • 浮动利润累计到激活阈值后启动跟踪止盈,只要回撤超过 TrailStepPoints 就平仓所有持仓。
    • 任一退出条件触发时,策略通过市价单平掉整个网格并重置状态。

参数

参数 说明 默认值
FastMaPeriod 进场过滤使用的快 EMA 长度。 10
MidMaPeriod 可选中间 EMA 长度,必须位于快慢线之间才确认信号,设为 0 可禁用。 25
SlowMaPeriod 进场过滤使用的慢 EMA 长度。 50
DisableMaFilter 启用后忽略 EMA 条件,直接跟随上一根蜡烛方向。 true
VolumePeriod 成交量平均周期,设为 0 表示不启用。 1
VolumeMinimum 允许开仓的最小平均成交量。 69
HighLevel 高于此价格禁止做多,并可能触发做空。 1.50001
LowLevel 低于此价格禁止做空,并可能触发做多。 1.40001
FirstVolume 每次网格循环的第一笔订单手数。 0.08
GridMap 用空格分隔的 距离/手数 列表,定义加仓触发距离和对应手数。 120/0.1 ... 120/0.19
TakeProfitPoints 应用于整体仓位加权成本的止盈距离(以价格步长计)。 10000
StopLossPoints 应用于整体仓位加权成本的止损距离(以价格步长计)。 30000
TrailStartPoints 启动跟踪止盈前所需的最小浮盈。 52
TrailDistancePoints 激活跟踪止盈所需的利润距离(减去跟踪步长)。 52
TrailStepPoints 跟踪止盈允许的最大利润回吐。 2
StartHour / StartMinute 交易会话起始时间(含)。 06:00
FinishHour / FinishMinute 交易会话结束时间(含,可跨夜)。 21:00
CandleType 策略处理的蜡烛数据类型。 1 分钟时间框架

使用说明

  • GridMap 采用不变文化格式解析,请在斜杠前输入点数距离,在斜杠后输入手数。
  • 所有止盈、止损与跟踪参数均会依据品种的 PriceStep 转换为实际价格差。运行前请确认证券对象的步长设置正确。
  • 跟踪止盈按照所有持仓的浮动利润之和工作,并在蜡烛收盘时检查;如需更快退出可使用更小的时间框架。
  • HighLevelLowLevel 触发的强制进场使用蜡烛收盘价近似买卖价差。
  • 与 MT4 版本不同,本实现检测到止盈、止损或跟踪条件时会一次性平掉全部仓位,便于使用高级 API 管理订单。

与 MT4 版本的差异

  • MT4 版本为每个订单单独设置保护价位,StockSharp 版本则针对组合头寸计算统一的退出点。
  • 由于蜡烛订阅默认不包含逐笔价差,买卖价使用蜡烛收盘价近似。
  • 跟踪退出的激活阈值取 TrailDistancePoints - TrailStepPointsTrailStartPoints 中较大者,以保证参数重叠时的稳定性。
  • 交易时段依据蜡烛的 DateTimeOffset,请确保数据源时间符合预期时区。

文件

  • CS/ProperBotStrategy.cs – 策略实现。
  • 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;
using System.Globalization;
using StockSharp.Algo;

namespace StockSharp.Samples.Strategies;



/// <summary>
/// Grid strategy converted from the "Proper Bot" MQL expert advisor.
/// </summary>
public class ProperBotStrategy : Strategy
{
	private readonly StrategyParam<int> _fastPeriod;
	private readonly StrategyParam<int> _midPeriod;
	private readonly StrategyParam<int> _slowPeriod;
	private readonly StrategyParam<bool> _disableMaFilter;
	private readonly StrategyParam<int> _volumePeriod;
	private readonly StrategyParam<decimal> _volumeMinimum;
	private readonly StrategyParam<decimal> _highLevel;
	private readonly StrategyParam<decimal> _lowLevel;
	private readonly StrategyParam<decimal> _firstVolume;
	private readonly StrategyParam<string> _gridMap;
	private readonly StrategyParam<int> _takeProfitPoints;
	private readonly StrategyParam<int> _stopLossPoints;
	private readonly StrategyParam<int> _trailStartPoints;
	private readonly StrategyParam<int> _trailDistancePoints;
	private readonly StrategyParam<int> _trailStepPoints;
	private readonly StrategyParam<int> _startHour;
	private readonly StrategyParam<int> _startMinute;
	private readonly StrategyParam<int> _finishHour;
	private readonly StrategyParam<int> _finishMinute;
	private readonly StrategyParam<DataType> _candleType;

	private readonly List<GridLevel> _gridLevels = new();
	private readonly List<GridOrder> _activeOrders = new();

	private ExponentialMovingAverage _fastEma;
	private ExponentialMovingAverage _midEma;
	private ExponentialMovingAverage _slowEma;
	private SimpleMovingAverage _volumeAverage;

	private decimal _priceStep;
	private Sides? _currentDirection;
	private int _nextGridIndex;
	private decimal _lastEntryPrice;
	private decimal _maxTrailingPoints;
	private bool _hasPreviousCandle;
	private decimal _previousOpen;
	private decimal _previousClose;
	private int _previousSignal;

	public int FastMaPeriod
	{
		get => _fastPeriod.Value;
		set => _fastPeriod.Value = value;
	}

	public int MidMaPeriod
	{
		get => _midPeriod.Value;
		set => _midPeriod.Value = value;
	}

	public int SlowMaPeriod
	{
		get => _slowPeriod.Value;
		set => _slowPeriod.Value = value;
	}

	public bool DisableMaFilter
	{
		get => _disableMaFilter.Value;
		set => _disableMaFilter.Value = value;
	}

	public int VolumePeriod
	{
		get => _volumePeriod.Value;
		set => _volumePeriod.Value = value;
	}

	public decimal VolumeMinimum
	{
		get => _volumeMinimum.Value;
		set => _volumeMinimum.Value = value;
	}

	public decimal HighLevel
	{
		get => _highLevel.Value;
		set => _highLevel.Value = value;
	}

	public decimal LowLevel
	{
		get => _lowLevel.Value;
		set => _lowLevel.Value = value;
	}

	public decimal FirstVolume
	{
		get => _firstVolume.Value;
		set => _firstVolume.Value = value;
	}

	public string GridMap
	{
		get => _gridMap.Value;
		set => _gridMap.Value = value;
	}

	public int TakeProfitPoints
	{
		get => _takeProfitPoints.Value;
		set => _takeProfitPoints.Value = value;
	}

	public int StopLossPoints
	{
		get => _stopLossPoints.Value;
		set => _stopLossPoints.Value = value;
	}

	public int TrailStartPoints
	{
		get => _trailStartPoints.Value;
		set => _trailStartPoints.Value = value;
	}

	public int TrailDistancePoints
	{
		get => _trailDistancePoints.Value;
		set => _trailDistancePoints.Value = value;
	}

	public int TrailStepPoints
	{
		get => _trailStepPoints.Value;
		set => _trailStepPoints.Value = value;
	}

	public int StartHour
	{
		get => _startHour.Value;
		set => _startHour.Value = value;
	}

	public int StartMinute
	{
		get => _startMinute.Value;
		set => _startMinute.Value = value;
	}

	public int FinishHour
	{
		get => _finishHour.Value;
		set => _finishHour.Value = value;
	}

	public int FinishMinute
	{
		get => _finishMinute.Value;
		set => _finishMinute.Value = value;
	}

	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	public ProperBotStrategy()
	{
		_fastPeriod = Param(nameof(FastMaPeriod), 10)
			.SetGreaterThanZero()
			.SetDisplay("Fast EMA", "Period of the fast EMA filter", "Signals")
			;

		_midPeriod = Param(nameof(MidMaPeriod), 25)
			.SetDisplay("Mid EMA", "Optional middle EMA period", "Signals")
			;

		_slowPeriod = Param(nameof(SlowMaPeriod), 50)
			.SetGreaterThanZero()
			.SetDisplay("Slow EMA", "Period of the slow EMA filter", "Signals")
			;

		_disableMaFilter = Param(nameof(DisableMaFilter), true)
			.SetDisplay("Disable EMA Filter", "Use previous candle direction instead of EMAs", "Signals")
			;

		_volumePeriod = Param(nameof(VolumePeriod), 1)
			.SetDisplay("Volume Period", "Number of candles for the volume filter", "Filters")
			;

		_volumeMinimum = Param(nameof(VolumeMinimum), 0m)
			.SetDisplay("Volume Minimum", "Minimal average volume to allow entries", "Filters")
			;

		_highLevel = Param(nameof(HighLevel), 1000000m)
			.SetDisplay("High Level", "Do not buy above this price", "Filters")
			;

		_lowLevel = Param(nameof(LowLevel), -1000000m)
			.SetDisplay("Low Level", "Do not sell below this price", "Filters")
			;

		_firstVolume = Param(nameof(FirstVolume), 0.08m)
			.SetDisplay("First Order Volume", "Volume for the first order in a grid cycle", "Risk")
			;

		_gridMap = Param(nameof(GridMap), string.Empty)
			.SetDisplay("Grid Map", "Distance/volume pairs separated by spaces", "Risk");

		_takeProfitPoints = Param(nameof(TakeProfitPoints), 10000)
			.SetDisplay("Take Profit Points", "Profit distance in price steps", "Risk")
			;

		_stopLossPoints = Param(nameof(StopLossPoints), 30000)
			.SetDisplay("Stop Loss Points", "Loss distance in price steps", "Risk")
			;

		_trailStartPoints = Param(nameof(TrailStartPoints), 52)
			.SetDisplay("Trail Start Points", "Minimal profit to arm the trailing exit", "Risk")
			;

		_trailDistancePoints = Param(nameof(TrailDistancePoints), 52)
			.SetDisplay("Trail Distance Points", "Profit distance required to enable trailing", "Risk")
			;

		_trailStepPoints = Param(nameof(TrailStepPoints), 2)
			.SetDisplay("Trail Step Points", "Allowed profit retracement before exit", "Risk")
			;

		_startHour = Param(nameof(StartHour), 0)
			.SetDisplay("Start Hour", "Trading session start hour", "Session")
			;

		_startMinute = Param(nameof(StartMinute), 0)
			.SetDisplay("Start Minute", "Trading session start minute", "Session")
			;

		_finishHour = Param(nameof(FinishHour), 23)
			.SetDisplay("Finish Hour", "Trading session end hour", "Session")
			;

		_finishMinute = Param(nameof(FinishMinute), 0)
			.SetDisplay("Finish Minute", "Trading session end minute", "Session")
			;

		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(5).TimeFrame())
			.SetDisplay("Candle Type", "Type of candles to process", "General");
	}

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

	protected override void OnReseted()
	{
		base.OnReseted();

		_gridLevels.Clear();
		_activeOrders.Clear();
		_fastEma = null;
		_midEma = null;
		_slowEma = null;
		_volumeAverage = null;
		_priceStep = 0m;
		_currentDirection = null;
		_nextGridIndex = 0;
		_lastEntryPrice = 0m;
		_maxTrailingPoints = decimal.MinValue;
		Volume = FirstVolume;
		_hasPreviousCandle = false;
		_previousOpen = 0m;
		_previousClose = 0m;
		_previousSignal = 0;
	}

	protected override void OnStarted2(DateTime time)
	{
		base.OnStarted2(time);

		ParseGridMap();

		_fastEma = new ExponentialMovingAverage { Length = Math.Max(1, FastMaPeriod) };
		_midEma = new ExponentialMovingAverage { Length = Math.Max(1, MidMaPeriod) };
		_slowEma = new ExponentialMovingAverage { Length = Math.Max(1, SlowMaPeriod) };
		_volumeAverage = new SimpleMovingAverage { Length = Math.Max(1, VolumePeriod) };

		_priceStep = Security?.PriceStep ?? 0.0001m;
		if (_priceStep <= 0m)
			_priceStep = 0.0001m;

		_maxTrailingPoints = decimal.MinValue;

		var subscription = SubscribeCandles(CandleType);
		subscription
			.Bind(_fastEma, _midEma, _slowEma, ProcessCandle)
			.Start();

		StartProtection(
			takeProfit: new Unit(2, UnitTypes.Percent),
			stopLoss: new Unit(1, UnitTypes.Percent));
	}

	protected override void OnOwnTradeReceived(MyTrade trade)
	{
		base.OnOwnTradeReceived(trade);

		if (trade == null)
			return;

		var direction = trade.Order.Side;
		var volume = trade.Trade.Volume;

		if (volume <= 0m)
			return;

		if (_currentDirection is null)
			_currentDirection = direction;

		if (_currentDirection == direction)
		{
			_activeOrders.Add(new GridOrder(trade.Trade.Price, volume));
			_lastEntryPrice = trade.Trade.Price;

			if (_activeOrders.Count <= 1)
			{
				_nextGridIndex = 0;
				_maxTrailingPoints = decimal.MinValue;
			}
			else
			{
				_nextGridIndex = Math.Min(_activeOrders.Count - 1, Math.Max(0, _gridLevels.Count - 1));
			}
		}
		else
		{
			ReducePosition(volume);

			if (_activeOrders.Count == 0)
			{
				_currentDirection = null;
				_lastEntryPrice = 0m;
				_nextGridIndex = 0;
				_maxTrailingPoints = decimal.MinValue;
			}
			else
			{
				_lastEntryPrice = _activeOrders[^1].Price;
				_nextGridIndex = Math.Min(_activeOrders.Count - 1, Math.Max(0, _gridLevels.Count - 1));
			}
		}
	}

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

		var signal = CalculateSignal(fastValue, midValue, slowValue);

		if (Position == 0 && signal != 0 && signal != _previousSignal)
		{
			if (signal > 0)
				BuyMarket();
			else
				SellMarket();
		}

		UpdatePreviousCandle(candle);
		_previousSignal = signal;
	}

	private int CalculateSignal(decimal fastValue, decimal midValue, decimal slowValue)
	{
		if (DisableMaFilter)
		{
			if (!_hasPreviousCandle)
				return 0;

			if (_previousClose > _previousOpen)
				return 1;

			if (_previousClose < _previousOpen)
				return -1;

			return 0;
		}

		if (!_slowEma.IsFormed)
			return 0;

		var fastSignal = fastValue.CompareTo(slowValue);

		if (fastSignal == 0)
			return 0;

		var signal = fastSignal > 0 ? 1 : -1;

		if (MidMaPeriod > 0)
		{
			if (!_midEma.IsFormed)
				return 0;

			if ((midValue >= fastValue && fastValue > slowValue) || (midValue <= fastValue && fastValue < slowValue))
				return 0;
		}

		return signal;
	}

	private bool CheckVolume(ICandleMessage candle)
	{
		if (VolumePeriod < 1)
			return true;

		var average = _volumeAverage.Process(new DecimalIndicatorValue(_volumeAverage, candle.TotalVolume, candle.CloseTime) { IsFinal = true }).ToDecimal();

		if (!_volumeAverage.IsFormed)
			return false;

		return average >= VolumeMinimum;
	}

	private void ApplyBoundaryFilters(ref int signal, decimal price)
	{
		var ask = price;
		var bid = price;

		if (ask > HighLevel)
			signal = 0;

		if (bid < LowLevel)
			signal = 0;

		if (ask < LowLevel)
			signal = 1;

		if (bid > HighLevel)
			signal = -1;
	}

	private bool IsWithinTradingHours(DateTimeOffset time)
	{
		var start = new TimeSpan(StartHour, StartMinute, 0);
		var end = new TimeSpan(FinishHour, FinishMinute, 0);
		var current = time.TimeOfDay;

		if (start == end)
			return true;

		if (start < end)
			return current >= start && current <= end;

		return current >= start || current <= end;
	}

	private void StartNewCycle(int signal)
	{
		if (FirstVolume <= 0m)
			return;

		if (signal > 0)
		{
			BuyMarket();
		}
		else if (signal < 0)
		{
			SellMarket();
		}
	}

	private bool ManageRisk(ICandleMessage candle)
	{
		if (_currentDirection is null || _activeOrders.Count == 0)
			return false;

		var averagePrice = CalculateAveragePrice();
		var close = candle.ClosePrice;
		var high = candle.HighPrice;
		var low = candle.LowPrice;
		var direction = _currentDirection == Sides.Buy ? 1m : -1m;
		var takeProfitDistance = ConvertPointsToPrice(TakeProfitPoints);
		var stopLossDistance = ConvertPointsToPrice(StopLossPoints);

		if (direction > 0)
		{
			if (TakeProfitPoints > 0 && high - averagePrice >= takeProfitDistance)
			{
				if (Position > 0) SellMarket(); else if (Position < 0) BuyMarket();
				return true;
			}

			if (StopLossPoints > 0 && averagePrice - low >= stopLossDistance)
			{
				if (Position > 0) SellMarket(); else if (Position < 0) BuyMarket();
				return true;
			}
		}
		else
		{
			if (TakeProfitPoints > 0 && averagePrice - low >= takeProfitDistance)
			{
				if (Position > 0) SellMarket(); else if (Position < 0) BuyMarket();
				return true;
			}

			if (StopLossPoints > 0 && high - averagePrice >= stopLossDistance)
			{
				if (Position > 0) SellMarket(); else if (Position < 0) BuyMarket();
				return true;
			}
		}

		if (TrailDistancePoints > 0 && TrailStepPoints > 0)
		{
			var points = CalculateFloatingPoints(close);
			var activation = (decimal)Math.Max(TrailDistancePoints - TrailStepPoints, TrailStartPoints);

			if (points >= activation)
			{
				if (_maxTrailingPoints == decimal.MinValue || points > _maxTrailingPoints)
				{
					_maxTrailingPoints = points;
				}
				else if (_maxTrailingPoints - points >= TrailStepPoints)
				{
					if (Position > 0) SellMarket(); else if (Position < 0) BuyMarket();
					return true;
				}
			}
		}

		return false;
	}

	private void ProcessGridExpansion(ICandleMessage candle)
	{
		if (_currentDirection is null || _activeOrders.Count == 0 || _gridLevels.Count == 0)
			return;

		var index = Math.Min(_nextGridIndex, _gridLevels.Count - 1);
		var level = _gridLevels[index];
		var distance = level.Distance * _priceStep;

		if (distance <= 0m)
			return;

		if (_currentDirection == Sides.Buy)
		{
			if (_lastEntryPrice - candle.LowPrice >= distance)
			{
				if (level.Volume > 0m)
					BuyMarket();
			}
		}
		else
		{
			if (candle.HighPrice - _lastEntryPrice >= distance)
			{
				if (level.Volume > 0m)
					SellMarket();
			}
		}
	}

	private void UpdatePreviousCandle(ICandleMessage candle)
	{
		_previousOpen = candle.OpenPrice;
		_previousClose = candle.ClosePrice;
		_hasPreviousCandle = true;
	}

	private decimal CalculateAveragePrice()
	{
		if (_activeOrders.Count == 0)
			return 0m;

		decimal sum = 0m;
		decimal volume = 0m;

		for (var i = 0; i < _activeOrders.Count; i++)
		{
			var order = _activeOrders[i];
			sum += order.Price * order.Volume;
			volume += order.Volume;
		}

		return volume > 0m ? sum / volume : 0m;
	}

	private decimal CalculateFloatingPoints(decimal price)
	{
		if (_activeOrders.Count == 0 || _priceStep <= 0m || _currentDirection is null)
			return 0m;

		var direction = _currentDirection == Sides.Buy ? 1m : -1m;
		decimal sum = 0m;

		for (var i = 0; i < _activeOrders.Count; i++)
		{
			var order = _activeOrders[i];
			sum += (price - order.Price) * direction / _priceStep;
		}

		return sum;
	}

	private decimal ConvertPointsToPrice(int points)
		=> points <= 0 ? 0m : points * _priceStep;

	private bool HasActiveCycle()
		=> _activeOrders.Count > 0;

	private void ReducePosition(decimal volume)
	{
		var remaining = volume;

		for (var i = _activeOrders.Count - 1; i >= 0 && remaining > 0m; i--)
		{
			var order = _activeOrders[i];

			if (order.Volume > remaining)
			{
				order.Volume -= remaining;
				remaining = 0m;
			}
			else
			{
				remaining -= order.Volume;
				_activeOrders.RemoveAt(i);
			}
		}
	}

	private void ParseGridMap()
	{
		_gridLevels.Clear();

		if (GridMap.IsEmptyOrWhiteSpace())
			return;

		var parts = GridMap.Split(new[] { ' ', ';', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);

		foreach (var part in parts)
		{
			var tokens = part.Split('/');

			if (tokens.Length != 2)
				continue;

			if (!decimal.TryParse(tokens[0], NumberStyles.Any, CultureInfo.InvariantCulture, out var distance))
				continue;

			if (!decimal.TryParse(tokens[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var volume))
				continue;

			if (distance <= 0m || volume <= 0m)
				continue;

			_gridLevels.Add(new GridLevel(distance, volume));
		}
	}

	private sealed class GridOrder
	{
		public GridOrder(decimal price, decimal volume)
		{
			Price = price;
			Volume = volume;
		}

		public decimal Price { get; set; }

		public decimal Volume { get; set; }
	}

	private readonly record struct GridLevel(decimal Distance, decimal Volume);
}